Files
Mihawk cf649dd5d7 Add guided WebUI settings for every included core except RPCS3
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
2026-10-02 13:06:01 -04:00

164 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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; });