mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-22 00:22:29 -05:00
Expose 846 options from 15 pinned PS5 core catalogs before first use,
with categories, descriptions, exact choices, saved-value preservation,
and Advanced mode. Include installed cores without saved profiles and
preserve first-use PPSSPP/Dolphin edits through their profile migration.
Bind dynamic runtime options to the catalog and core binary revision.
Validation: all five tools/verify.sh gates pass (96 tests), all 15 core
browser checks pass, and the deployed native menu-only run exposes all
846 catalog keys with matching JSON, build identity and normal return.
Restore original settings and stop the temporary helper after testing.
Evidence: evidence/webui-settings-guide/{capture,expectation}.json
Reproduce: python3 tools/evidence.py compare evidence/
Raw native record: klog/webui-guide/all-cores-20261002-130144/report.json
164 lines
15 KiB
JavaScript
164 lines
15 KiB
JavaScript
/* Run against an isolated native webui_server_main fixture. No production console writes. */
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright');
|
||
const base = process.env.WEBUI_TEST_URL || 'http://127.0.0.1:6770';
|
||
const out = path.resolve(process.env.WEBUI_SCREENSHOTS || '.impeccable/review');
|
||
const release = tag => ({ tag_name: tag, published_at: '2026-10-01T13:43:33Z', draft: false, prerelease: true, body: '## What changed\nMore reliable emulation.\n<script>window.notesExecuted=true</script>' });
|
||
(async () => {
|
||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium' });
|
||
try {
|
||
const context = await browser.newContext({ viewport: { width: 1504, height: 1046 }, reducedMotion: 'reduce' });
|
||
const page = await context.newPage();
|
||
const errors = []; page.on('pageerror', e => errors.push(e.message));
|
||
let releases = [release('v0.5.7-alpha.5')], fail = false;
|
||
await page.route('https://api.github.com/**', route => fail ? route.abort() : route.fulfill({ json: releases }));
|
||
await page.goto(base);
|
||
await page.waitForFunction(() => document.querySelector('#release-summary').textContent.includes('up to date'));
|
||
await page.waitForFunction(() => !document.querySelector('#browse-files').disabled);
|
||
assert.equal(await page.locator('.folder-row').count(), 3);
|
||
assert.equal(await page.locator('#connection').innerText(), 'RetroArch is running');
|
||
for (const [latest, state] of [['v0.5.7-alpha.6', 'update'], ['v0.5.7-alpha.10', 'update'], ['v0.5.7-alpha.4', 'current'], ['v0.5.7', 'update'], ['v0.5.6', 'current'], ['unversioned', 'development']]) {
|
||
releases = [release(latest)]; await page.locator('#check-release').click();
|
||
await page.waitForFunction(expected => document.querySelector('.release-bar').dataset.state === expected && !document.querySelector('#check-release').disabled, state);
|
||
}
|
||
releases = []; await page.locator('#check-release').click(); await page.waitForFunction(() => document.querySelector('#release-summary').textContent.includes('No published'));
|
||
fail = true; await page.locator('#check-release').click(); await page.waitForFunction(() => document.querySelector('#release-summary').textContent.includes('Couldn’t'));
|
||
fail = false; releases = [release('v0.5.7-alpha.5')]; await page.locator('#check-release').click(); await page.waitForFunction(() => document.querySelector('.release-bar').dataset.state === 'current');
|
||
await page.locator('#show-notes').click(); assert.match(await page.locator('#release-notes').innerText(), /<script>/); assert.equal(await page.evaluate(() => window.notesExecuted), undefined);
|
||
await page.locator('#close-notes').click();
|
||
const filename = `webui-browser-${Date.now()}.bin`, payload = Buffer.alloc(128 * 1024, 75);
|
||
await page.locator('#destination').selectOption('PSP');
|
||
const [firstPicker] = await Promise.all([page.waitForEvent('filechooser'), page.locator('#browse-files').click()]);
|
||
await firstPicker.setFiles({ name: filename, mimeType: 'application/octet-stream', buffer: payload });
|
||
await page.waitForFunction(name => [...document.querySelectorAll('#recent-transfers .transfer-row')].some(n => n.textContent.includes(name) && n.textContent.includes('Uploaded')), filename);
|
||
await page.locator('.folder-row').filter({ hasText: 'PSP' }).click();
|
||
const fileRow = page.locator('.content-row').filter({ hasText: filename }); await fileRow.waitFor();
|
||
const [download] = await Promise.all([page.waitForEvent('download'), fileRow.getByText('Download', { exact: true }).click()]);
|
||
const downloaded = await download.path(); assert.deepEqual(fs.readFileSync(downloaded), payload);
|
||
await page.locator('[data-page="overview"]').click();
|
||
const [secondPicker] = await Promise.all([page.waitForEvent('filechooser'), page.locator('#browse-files').click()]);
|
||
await secondPicker.setFiles({ name: filename, mimeType: 'application/octet-stream', buffer: Buffer.from('replace') });
|
||
await page.waitForFunction(() => document.querySelector('#recent-transfers').textContent.includes('already exists'));
|
||
// Real throttled upload: progress must not replace the focused Cancel control.
|
||
const cdp = await context.newCDPSession(page);
|
||
await cdp.send('Network.enable');
|
||
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: 64 * 1024 });
|
||
const cancelledName = `cancel-${Date.now()}.bin`;
|
||
const [cancelPicker] = await Promise.all([page.waitForEvent('filechooser'), page.locator('#browse-files').click()]);
|
||
await cancelPicker.setFiles({ name: cancelledName, mimeType: 'application/octet-stream', buffer: Buffer.alloc(1024 * 1024, 42) });
|
||
const cancelRow = page.locator('#recent-transfers .transfer-row').filter({ hasText: cancelledName });
|
||
const cancelButton = cancelRow.getByRole('button', { name: 'Cancel' });
|
||
await cancelButton.focus();
|
||
await page.waitForFunction(name => [...document.querySelectorAll('#recent-transfers .transfer-row')].some(row => row.textContent.includes(name) && row.querySelector('progress')?.value >= 10), cancelledName);
|
||
assert.equal(await cancelButton.evaluate(button => document.activeElement === button), true, 'Cancel stays focused across progress updates');
|
||
await page.keyboard.press('Enter');
|
||
await page.waitForFunction(name => [...document.querySelectorAll('#recent-transfers .transfer-row')].some(row => row.textContent.includes(name) && row.textContent.includes('Cancelled')), cancelledName);
|
||
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||
await cdp.detach();
|
||
const cancelledListing = await page.evaluate(async () => (await (await fetch('/api/content?path=PSP')).json()).entries);
|
||
assert.equal(cancelledListing.some(entry => entry.name === cancelledName), false, 'Cancelled upload is not published');
|
||
await page.locator('[data-page="settings"]').click();
|
||
// Each bundled core must expose its whole guide before a game has run.
|
||
const catalogs = await page.evaluate(async () => {
|
||
const { cores } = await (await fetch('/api/cores')).json();
|
||
return Promise.all(cores.map(async core => ({ core, metadata: await (await fetch('/api/core-metadata?core=' + encodeURIComponent(core))).json() })));
|
||
});
|
||
const bundled = catalogs.filter(c => c.metadata.bundled.settings.length);
|
||
if (process.env.WEBUI_CORE_COUNT) assert.equal(bundled.length, Number(process.env.WEBUI_CORE_COUNT));
|
||
for (const { core, metadata } of bundled) {
|
||
await page.locator('#settings-profile').selectOption(core);
|
||
await page.waitForFunction(name => document.querySelector('#settings-heading').textContent.startsWith(name + ' · Core options') && !document.querySelector('#save-settings').disabled, core);
|
||
assert.ok(await page.locator('#settings-categories button').count(), core + ' categories');
|
||
const counts = await page.evaluate(() => ({ settings: editorSettings.length, guided: editorSettings.filter(s => settingGuide(s)).length, missing: editorSettings.filter(s => !settingGuide(s)?.description).map(s => s.key) }));
|
||
assert.ok(counts.settings >= metadata.bundled.settings.length, core + ' option coverage');
|
||
assert.equal(counts.guided, counts.settings, core + ' all settings guided');
|
||
assert.deepEqual(counts.missing, [], core + ' descriptions');
|
||
}
|
||
await page.locator('#settings-profile').selectOption('');
|
||
await page.waitForFunction(() => document.querySelector('#settings-heading').textContent === 'Global RetroArch settings' && !document.querySelector('#save-settings').disabled);
|
||
await page.locator('#settings-advanced').click();
|
||
await page.locator('#settings-search').fill('audio_volume');
|
||
await page.locator('#setting-audio_volume').fill('-12'); await page.locator('#save-settings').click();
|
||
await page.waitForFunction(() => /Saved|No changes to save/.test(document.querySelector('#settings-result').textContent));
|
||
const settings = await page.evaluate(async () => (await (await fetch('/api/settings')).json()).settings);
|
||
assert.equal(settings.find(s => s.key === 'audio_volume').value, '-12');
|
||
await page.locator('#settings-search').fill('');
|
||
await page.locator('#settings-profile').selectOption('PPSSPP');
|
||
await page.waitForFunction(() => document.querySelector('#settings-heading').textContent.startsWith('PPSSPP'));
|
||
await page.locator('#settings-guided').click();
|
||
await page.locator('#settings-categories button').filter({ hasText: /^Video$/ }).click();
|
||
assert.equal(await page.locator('#setting-ppsspp_internal_resolution').evaluate(n => n.tagName), 'SELECT');
|
||
assert.match(await page.locator('#setting-ppsspp_internal_resolution-help').innerText(), /GPU memory/);
|
||
assert.deepEqual(await page.locator('#settings-categories button').allTextContents(), ['System','Video','Input','Hacks','Network']);
|
||
const newResolution = await page.locator('#setting-ppsspp_internal_resolution').inputValue() === '1920x1088' ? '2880x1632' : '1920x1088';
|
||
await page.locator('#setting-ppsspp_internal_resolution').selectOption(newResolution);
|
||
await page.locator('#settings-categories button').filter({ hasText: /^Input$/ }).click();
|
||
await page.locator('#setting-ppsspp_button_preference').selectOption(await page.locator('#setting-ppsspp_button_preference').inputValue() === 'Circle' ? 'Cross' : 'Circle');
|
||
await page.locator('#settings-advanced').click();
|
||
await page.locator('#settings-search').fill('ppsspp_internal_resolution');
|
||
assert.equal(await page.locator('#setting-ppsspp_internal_resolution').inputValue(), newResolution, 'Draft survives category and mode changes');
|
||
await page.locator('#settings-profile').selectOption('');
|
||
assert.equal(await page.locator('#settings-profile').inputValue(), 'PPSSPP', 'Unsaved changes block profile switching');
|
||
await page.locator('#settings-guided').click();
|
||
await page.locator('#settings-search').fill('jagged');
|
||
assert.equal(await page.locator('#setting-ppsspp_mulitsample_level').inputValue(), 'x8', 'Descriptions are searchable');
|
||
assert.match(await page.locator('#settings-result').innerText(), /2 unsaved/);
|
||
await page.locator('#save-settings').click();
|
||
await page.waitForFunction(() => /Saved|No changes to save/.test(document.querySelector('#settings-result').textContent));
|
||
await page.locator('#settings-kind').selectOption('core-settings');
|
||
await page.locator('#settings-search').fill('video_vsync');
|
||
await page.locator('#setting-video_vsync').uncheck();
|
||
await page.locator('#save-settings').click();
|
||
await page.waitForFunction(() => /Saved|No changes to save/.test(document.querySelector('#settings-result').textContent));
|
||
await page.locator('#settings-profile').selectOption('');
|
||
await page.locator('#settings-search').fill('video_vsync');
|
||
await page.waitForFunction(() => document.querySelector('#settings-heading').textContent === 'Global RetroArch settings');
|
||
assert.equal(await page.locator('#setting-video_vsync').isChecked(), true, 'Core override must not change global value');
|
||
await page.locator('#settings-search').fill('');
|
||
await page.locator('#settings-advanced').click();
|
||
await page.locator('#settings-next').click();
|
||
assert.match(await page.locator('#settings-page').innerText(), /Page 2/);
|
||
await page.locator('#settings-previous').click();
|
||
await page.locator('[data-page="content"]').click();
|
||
await page.locator('#folder-name').fill('browser-created-' + Date.now()); await page.locator('#folder-form button').click();
|
||
await page.waitForFunction(() => document.querySelector('#announcement').textContent.startsWith('Created'));
|
||
// A full reload proves that settings come from the server, not optimistic browser state.
|
||
await page.reload(); await page.waitForFunction(() => document.querySelector('#quick-volume').value === '-12');
|
||
await page.locator('[data-page="overview"]').click();
|
||
await page.evaluate(() => { document.querySelector('#announcement').textContent = ''; });
|
||
await page.locator('[data-page="transfers"]').click(); await page.locator('#clear-transfers').click(); await page.locator('[data-page="overview"]').click();
|
||
await page.evaluate(() => document.fonts.ready);
|
||
fs.mkdirSync(out, { recursive: true });
|
||
// One bounded visual round across the supported sizes and representative pages.
|
||
for (const [width, height, name] of [[3440, 1250, 'ultrawide'], [1920, 1080, 'desktop'], [1366, 768, 'laptop'], [390, 844, 'mobile']]) {
|
||
await page.setViewportSize({ width, height });
|
||
await page.screenshot({ path: path.join(out, name + '.png'), fullPage: true });
|
||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).fontSize), '16px', 'Screen width must not enlarge all controls');
|
||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `${name} horizontal overflow`);
|
||
}
|
||
await page.locator('[data-page="content"]').click(); await page.locator('#content-list .content-row').first().waitFor();
|
||
await page.screenshot({ path: path.join(out, 'mobile-content.png'), fullPage: true });
|
||
await page.locator('[data-page="settings"]').click(); await page.locator('#settings-guided').click(); await page.screenshot({ path: path.join(out, 'mobile-settings.png'), fullPage: true });
|
||
await page.setViewportSize({ width: 1920, height: 1080 }); await page.screenshot({ path: path.join(out, 'desktop-global-settings.png'), fullPage: true });
|
||
await page.locator('#settings-profile').selectOption('PPSSPP'); await page.locator('#settings-kind').selectOption('core-options');
|
||
await page.waitForFunction(() => document.querySelector('#settings-heading').textContent.includes('Core options'));
|
||
await page.locator('#settings-categories button').filter({ hasText: /^Video$/ }).click();
|
||
await page.locator('#setting-ppsspp_internal_resolution').waitFor();
|
||
await page.screenshot({ path: path.join(out, 'desktop-core-settings.png'), fullPage: true });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.screenshot({ path: path.join(out, 'mobile-core-settings.png'), fullPage: true });
|
||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, 'Core settings mobile overflow');
|
||
await page.locator('[data-page="overview"]').click(); await page.locator('#theme').selectOption('dark');
|
||
await page.screenshot({ path: path.join(out, 'mobile-dark.png'), fullPage: true });
|
||
await page.locator('[data-page="settings"]').click();
|
||
await page.screenshot({ path: path.join(out, 'mobile-core-dark.png'), fullPage: true });
|
||
await page.locator('[data-page="overview"]').click();
|
||
await page.reload(); assert.equal(await page.locator('html').getAttribute('data-theme'), 'dark');
|
||
await page.locator('#theme').selectOption('light');
|
||
assert.deepEqual(errors, [], 'Browser JavaScript errors');
|
||
console.log('PASS: release ordering/current/update/unknown/offline/empty, safe notes, real upload/download, duplicate refusal, keyboard cancellation during upload, guided categories, descriptions, exact dropdown values, drafts across modes, global/core isolation, advanced pagination, settings persistence, folder creation, navigation, mobile overflow and saved theme.');
|
||
} finally { await browser.close(); }
|
||
})().catch(error => { console.error(error); process.exitCode = 1; });
|