mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-19 17:02:17 +02:00
1260 lines
79 KiB
JavaScript
1260 lines
79 KiB
JavaScript
// Glass Shell runtime module "react-lab" (P2): the test-only lab route /library/lgs/lab and P2's
|
|
// acceptance tests RX-1 to RX-7. Contract: docs/phase2/contracts/react.md section 9.
|
|
//
|
|
// Flag `reactLab` (off by default): installed only inside a locked lab step, e.g.
|
|
// python glass.py js --flags reactLab "__LGS_RT.use('react-lab').run('RX-1', L)"
|
|
// Everything here only navigates, moves controller focus, opens and cancels its OWN menus and modals,
|
|
// and logs. Actions run only through react.actions in test mode, behind spies. Nothing is launched,
|
|
// confirmed or changed.
|
|
(() => {
|
|
'use strict';
|
|
const RT = window.__LGS_RT || window.__LGS_RT_TEST;
|
|
if (!RT || typeof RT.define !== 'function') return;
|
|
|
|
const LAB = '/library/lgs/lab';
|
|
const SELF = '/library/lgs/lab/selftest';
|
|
const RAW = '/library/lgs/lab/raw';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const BTN = { A: 1, B: 2, X: 3, Y: 4, MENU: 14, UP: 9, DOWN: 10, LEFT: 11, RIGHT: 12 };
|
|
|
|
let rt = null, react = null, S = null;
|
|
|
|
const CSS = `
|
|
.lgsx-root{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:26px;padding:12px 48px 18px;box-sizing:border-box;color:#fff}
|
|
.lgsx-title{font-size:34px;font-weight:700}
|
|
.lgsx-sub{font-size:20px;opacity:.72}
|
|
.lgsx-grid{display:grid;grid-template-columns:repeat(3,176px);gap:22px 64px;justify-content:center}
|
|
.lgsx-tile{display:flex;flex-direction:column;align-items:center;gap:10px;padding:8px;border-radius:24px;outline:none;background:transparent}
|
|
.lgsx-disc{width:128px;height:128px;border-radius:50%;overflow:hidden;background:rgba(255,255,255,.14);display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700}
|
|
.lgsx-disc img{width:100%;height:100%;object-fit:cover}
|
|
.lgsx-tile.gpfocus .lgsx-disc{scale:1.1;box-shadow:0 0 18px 2px rgba(255,255,255,.3)}
|
|
.lgsx-label{font-size:18px;max-width:176px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.lgsx-bar{display:flex;gap:18px;align-items:center}
|
|
.lgsx-bar .DialogButton{min-width:180px;min-height:60px;border-radius:30px}
|
|
.lgsx-toggle{width:340px}
|
|
.lgsx-lab{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:#fff}
|
|
`;
|
|
|
|
function newState() {
|
|
return { fixtures: new Map(), events: [], listeners: new Set(), handles: [], labRoute: null, labEl: null, labSeq: 0, labError: null,
|
|
rawRoute: null, rawEl: null, menu: null, spies: [] };
|
|
}
|
|
function emit(type, item) {
|
|
const e = { t: Date.now(), type, kind: item && item.kind, id: item && item.id, name: item && item.name };
|
|
S.events.push(e);
|
|
if (S.events.length > 80) S.events.shift();
|
|
for (const fn of S.listeners) { try { fn(e); } catch (_) { /* unmounted */ } }
|
|
}
|
|
const doc = () => react.nav.win().document;
|
|
const route = () => react.nav.route();
|
|
const FNC = () => window.FocusNavController;
|
|
|
|
// ------------------------------------------------------------ built-in fixtures (Steam's components, fake props)
|
|
function builtinFixtures() {
|
|
const noop = () => {};
|
|
S.fixtures.set('ConfirmModal', (r) => r.jsx(r.c.ConfirmModal, {
|
|
strTitle: 'Lab fixture', strDescription: 'Steam ConfirmModal with no-op handlers (P2 RX-4).',
|
|
onOK: noop, onCancel: noop, closeModal: noop,
|
|
}));
|
|
S.fixtures.set('buttons', (r) => r.jsxs(r.c.Focusable, { 'flow-children': 'row', style: { display: 'flex', gap: '24px' }, children: [
|
|
r.jsx(r.c.DialogButton, { onClick: noop, children: 'Secondary' }, 'a'),
|
|
r.c.DialogButtonPrimary ? r.jsx(r.c.DialogButtonPrimary, { onClick: noop, children: 'Primary' }, 'b') : null,
|
|
] }));
|
|
S.fixtures.set('fields', (r) => r.jsx(FieldsFixture, {}));
|
|
// A component that throws while rendering (ui.ErrorBoundary, lab.open() error reporting, RX-FAIL).
|
|
S.fixtures.set('boom', (r) => r.jsx(BoomFixture, {}));
|
|
}
|
|
function BoomFixture() { throw new Error('react-lab: forced render error (fixture boom)'); }
|
|
// A lab click: the events P10's L.click dispatches (untrusted pointer and mouse events; the 'click'
|
|
// itself is a MouseEvent).
|
|
function labClick(el) {
|
|
const w = react.nav.win(), r = el.getBoundingClientRect();
|
|
const o = { bubbles: true, cancelable: true, view: w, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2, button: 0 };
|
|
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) el.dispatchEvent(new (t.startsWith('pointer') ? w.PointerEvent : w.MouseEvent)(t, Object.assign({ pointerType: 'mouse', isPrimary: true }, o)));
|
|
}
|
|
function FieldsFixture() {
|
|
const R = react.React;
|
|
const [on, setOn] = R.useState(false);
|
|
const [v, setV] = R.useState(50);
|
|
const c = react.c;
|
|
return react.jsxs(c.Focusable, { 'flow-children': 'column', style: { width: '640px' }, children: [
|
|
c.ToggleField ? react.jsx(c.ToggleField, { label: 'Toggle (local state)', checked: on, onChange: setOn }, 't') : null,
|
|
c.SliderField ? react.jsx(c.SliderField, { label: 'Slider (local state)', value: v, min: 0, max: 100, step: 1, onChange: setV }, 's') : null,
|
|
] });
|
|
}
|
|
|
|
// ------------------------------------------------------------ lab route
|
|
// The fixture renders inside its own ui.ErrorBoundary, keyed per open() so a new fixture never inherits
|
|
// an earlier one's error state; the boundary reports what it caught (contract §9: {mounted, error}).
|
|
function LabPage() {
|
|
const el = S && S.labEl;
|
|
const seq = S ? S.labSeq : 0;
|
|
return react.jsx(react.ui.Page, {
|
|
name: 'lab', className: 'lgsx-lab',
|
|
onCancel: () => react.nav.back(),
|
|
children: [react.ui.style(CSS), el ? react.jsx(react.ui.ErrorBoundary, {
|
|
name: 'lab fixture', key: 'f' + seq,
|
|
onError: (e) => { if (S && S.labSeq === seq) S.labError = String((e && e.message) || e); },
|
|
children: el,
|
|
}) : null],
|
|
});
|
|
}
|
|
function fixtureElement(what, opts) {
|
|
if (typeof what !== 'string') return what;
|
|
const f = S.fixtures.get(what);
|
|
if (!f) throw new Error('react-lab: no fixture ' + what);
|
|
return f(react, (opts && opts.props) || {});
|
|
}
|
|
async function open(what, opts) {
|
|
opts = opts || {};
|
|
const el = fixtureElement(what, opts);
|
|
S.labSeq++;
|
|
S.labError = null;
|
|
S.labEl = el;
|
|
if (!S.labRoute) S.labRoute = react.routes.add(LAB, LabPage, { exact: true, owner: 'react-lab' });
|
|
if (route() === LAB) react.nav.go(LAB, true); else react.nav.go(LAB);
|
|
let mounted = false;
|
|
for (let i = 0; i < 20 && !mounted; i++) { await sleep(100); mounted = !!doc().querySelector('.lgsx-lab'); }
|
|
await sleep(300);
|
|
const lab = doc().querySelector('.lgsx-lab');
|
|
// The fixture's boundary caught: not mounted, with the error (C7 labels such fixtures "unverified").
|
|
const failNode = !!(lab && lab.querySelector('.lgs-react-fail'));
|
|
const error = S.labError || (failNode ? 'fixture render failed (error page shown)' : null);
|
|
return { mounted: mounted && !error, error, route: route(), nodes: lab ? lab.querySelectorAll('*').length : 0 };
|
|
}
|
|
// A fixture as the WHOLE page of a real added route (no lab wrapper around it), at /library/lgs/lab/raw:
|
|
// what a package's routes.add() page gets, e.g. ui.ErrorBoundary's error page (RX-FAIL, shots).
|
|
async function openRaw(what, opts) {
|
|
opts = opts || {};
|
|
const el = fixtureElement(what, opts);
|
|
S.rawEl = el;
|
|
if (!S.rawRoute) S.rawRoute = react.routes.add(RAW, function LabRaw() { return S && S.rawEl ? S.rawEl : null; }, { exact: true, owner: 'react-lab' });
|
|
if (route() === RAW) react.nav.go(RAW, true); else react.nav.go(RAW);
|
|
await sleep(opts.settleMs || 1200);
|
|
return { route: route(), failPage: !!doc().querySelector('.lgs-react-fail') };
|
|
}
|
|
async function close() {
|
|
for (const p of [LAB, RAW]) {
|
|
if (route() === p) {
|
|
react.nav.back();
|
|
await sleep(900);
|
|
if (route() === p) { react.nav.go('/library/home', true); await sleep(600); }
|
|
}
|
|
}
|
|
if (S.labRoute) { S.labRoute.remove(); S.labRoute = null; }
|
|
if (S.rawRoute) { S.rawRoute.remove(); S.rawRoute = null; }
|
|
S.labEl = null; S.rawEl = null; S.labError = null;
|
|
await sleep(300);
|
|
return { routeLeft: route() !== LAB && route() !== RAW, route: route(), nodesLeft: doc().querySelectorAll('.lgsx-lab').length, routes: react.routes.list() };
|
|
}
|
|
|
|
// ------------------------------------------------------------ RX-1: the SR §6 selftest, ported
|
|
function makeSelftestPage() {
|
|
const R = react.React, jsx = react.jsx, jsxs = react.jsxs, c = react.c;
|
|
function Tile({ item, autoFocus }) {
|
|
return jsxs(c.Focusable, {
|
|
className: 'lgsx-tile', autoFocus, noFocusRing: true,
|
|
onActivate: () => emit('activate', item),
|
|
onOKActionDescription: 'Open (logged only)',
|
|
onMenuButton: (e) => openMenu(item, e && e.currentTarget),
|
|
onMenuActionDescription: 'Options',
|
|
'data-lgsx': item.kind + ':' + item.id,
|
|
children: [
|
|
jsx('div', { className: 'lgsx-disc', children: item.art ? jsx('img', { src: item.art, alt: '' }) : (item.name || '?').slice(0, 1) }, 'd'),
|
|
jsx('div', { className: 'lgsx-label', children: item.name }, 'l'),
|
|
],
|
|
});
|
|
}
|
|
function openMenu(item, anchor) {
|
|
S.menu = react.ui.menu([
|
|
{ label: 'Open (logged only)', onSelected: () => emit('menu-open', item) },
|
|
{ label: 'Details (logged only)', onSelected: () => emit('menu-details', item) },
|
|
], anchor, { label: item.name });
|
|
emit('menu', item);
|
|
}
|
|
return function SelftestPage() {
|
|
const [labels, setLabels] = R.useState(true);
|
|
const programs = react.data.useNonSteamApps({});
|
|
const items = R.useMemo(() => {
|
|
const progs = (programs || []).slice(0, 3).map((p) => ({ kind: 'program', id: p.key, name: p.name, art: p.iconUrl }));
|
|
const games = react.data.installedGames({ limit: 6 - progs.length }).map((g) => ({ kind: 'game', id: String(g.appid), name: g.name, art: react.data.art(g.overview).portrait[0] || null }));
|
|
return games.concat(progs);
|
|
}, [programs]);
|
|
const back = () => { emit('back'); react.nav.back(); };
|
|
return jsx(react.ui.Page, {
|
|
name: 'selftest', className: 'lgsx-root' + (labels ? '' : ' lgsx-nolabels'), onCancel: back,
|
|
children: [
|
|
react.ui.style(CSS),
|
|
jsx('div', { className: 'lgsx-title', children: 'P2 selftest' }, 'h'),
|
|
jsx(c.Focusable, { 'flow-children': 'grid', className: 'lgsx-grid', children: items.map((it, i) => jsx(Tile, { item: it, autoFocus: i === 0 }, it.kind + ':' + it.id)) }, 'grid'),
|
|
jsxs(c.Focusable, { 'flow-children': 'row', className: 'lgsx-bar', children: [
|
|
jsx(c.DialogButton, { className: 'lgsx-back', onClick: back, children: react.ui.text('#Button_Back', 'Back') }, 'b'),
|
|
c.ToggleField ? jsx('div', { className: 'lgsx-toggle', children: jsx(c.ToggleField, { label: 'Show labels', checked: labels, bottomSeparator: 'none', onChange: (v) => { setLabels(v); emit('toggle', { kind: 'local', id: 'labels', name: String(v) }); } }) }, 't') : null,
|
|
] }, 'bar'),
|
|
],
|
|
});
|
|
};
|
|
}
|
|
function where() {
|
|
const f = doc().querySelector('.gpfocus');
|
|
if (!f) return 'none';
|
|
if (f.getAttribute('data-lgsx')) return f.getAttribute('data-lgsx');
|
|
if (f.closest('.BasicUIContextMenu') || f.closest('[role=menu]')) return 'menu:' + (f.innerText || '').trim().split('\n')[0];
|
|
if (f.closest('.lgsx-toggle')) return 'toggle';
|
|
if (f.closest('.lgsx-root')) return f.classList.contains('lgsx-back') || f.closest('.lgsx-back') ? 'back-button' : 'page';
|
|
// ui.ErrorBoundary's error page, only on the lab's own raw route (RX-FAIL).
|
|
if (f.closest('.lgs-react-fail') && route() === RAW) return 'fail-page';
|
|
return 'outside';
|
|
}
|
|
const inPage = (w) => /^(game|program):/.test(w) || ['toggle', 'back-button', 'page', 'fail-page'].includes(w);
|
|
// A, B and MENU only while controller focus is inside our page or our own menu, and main is the
|
|
// active focus context (never on Steam's own controls; never-list).
|
|
function press(name) {
|
|
const code = BTN[name];
|
|
if (!code) throw new Error('press: unknown button');
|
|
const w = where();
|
|
const ours = inPage(w) || (!!S.menu && /^menu:/.test(w));
|
|
if (!ours) throw new Error('press: controller focus is not inside the lab page or its menu (' + w + '); refusing');
|
|
const fnc = FNC();
|
|
const ctx = fnc.GetActiveContext && fnc.GetActiveContext();
|
|
const mainCtx = react.nav.inst().GetFocusNavContext && react.nav.inst().GetFocusNavContext();
|
|
if (ctx && mainCtx && ctx !== mainCtx) throw new Error('press: main is not the active focus context; refusing');
|
|
fnc.DispatchVirtualButtonClick(code);
|
|
return name;
|
|
}
|
|
function navState() {
|
|
try {
|
|
const fnc = FNC();
|
|
const ctx = fnc.GetActiveContext && fnc.GetActiveContext();
|
|
const tree = ctx && ctx.m_LastActiveNavTree;
|
|
const src = fnc.m_navigationSource && fnc.m_navigationSource.m_currentValue;
|
|
return (tree ? tree.id : '?') + ' source ' + (src ? src.eActivationSourceType : '?');
|
|
} catch (_) { return '?'; }
|
|
}
|
|
async function selftestOnce(rep) {
|
|
const check = (name, got, want) => {
|
|
const pass = want instanceof RegExp ? want.test(String(got)) : got === want;
|
|
rep.steps.push((pass ? 'PASS ' : 'FAIL ') + name + ': ' + got + (pass ? '' : ' (want ' + want + ')'));
|
|
if (pass) rep.pass++; else { rep.fail++; rep.ok = false; }
|
|
return pass;
|
|
};
|
|
const pad = async (dir) => {
|
|
if (where() === 'none') await refocus();
|
|
const before = where();
|
|
if (!inPage(before)) throw new Error('focus is not in the page (' + before + '); refusing to send ' + dir);
|
|
FNC().DispatchVirtualButtonClick(BTN[dir.toUpperCase()]);
|
|
await sleep(260);
|
|
return where();
|
|
};
|
|
const refocus = async () => {
|
|
for (let i = 0; i < 8 && !/^(game|program):/.test(where()); i++) {
|
|
if (where() === 'none') { react.nav.focusRoot(); await sleep(400); }
|
|
if (where() === 'none' || where() === 'outside') FNC().DispatchVirtualButtonClick(BTN.DOWN);
|
|
else FNC().DispatchVirtualButtonClick(BTN.UP);
|
|
await sleep(300);
|
|
}
|
|
if (!/^(game|program):/.test(where())) rep.steps.push('NOTE focus not on a tile after refocus: ' + where() + ' (' + navState() + ')');
|
|
return where();
|
|
};
|
|
const click = labClick;
|
|
const since = (n) => S.events.slice(n).map((e) => e.type + (e.id ? ' ' + e.id : ''));
|
|
if (route() !== SELF) { react.nav.go(SELF); await sleep(1600); }
|
|
check('route', route(), SELF);
|
|
const t = [...doc().querySelectorAll('.lgsx-tile')].map((e) => e.getAttribute('data-lgsx'));
|
|
check('tiles', t.length, 6);
|
|
const plan = [['right', t[1]], ['right', t[2]], ['right', t[2]], ['down', t[5]], ['left', t[4]], ['left', t[3]],
|
|
['up', t[0]], ['down', t[3]], ['down', /^(back-button|toggle)$/], ['right', 'toggle'], ['up', /^(game|program):/]];
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
await refocus();
|
|
for (let i = 0; i < 6 && t.indexOf(where()) !== 0; i++) await pad(t.indexOf(where()) >= 3 ? 'up' : 'left');
|
|
const got = [where()];
|
|
for (const [d] of plan) got.push(await pad(d));
|
|
if (got.includes('none') && attempt < 3) { rep.steps.push('NOTE focus interrupted (' + navState() + '), retrying'); continue; }
|
|
check('start', got[0], t[0]);
|
|
plan.forEach(([d, want], i) => check('pad ' + d, got[i + 1], want));
|
|
break;
|
|
}
|
|
let n = S.events.length;
|
|
click([...doc().querySelectorAll('.lgsx-tile')].find((e) => e.getAttribute('data-lgsx') === t[1]));
|
|
await sleep(300);
|
|
check('laser click', since(n).filter((e) => e.startsWith('activate')).join(), 'activate ' + t[1].split(':').slice(1).join(':'));
|
|
await refocus();
|
|
const focusedTile = where();
|
|
n = S.events.length;
|
|
press('A'); await sleep(300);
|
|
check('A on tile', since(n).filter((e) => e.startsWith('activate')).join(), 'activate ' + focusedTile.split(':').slice(1).join(':'));
|
|
press('MENU'); await sleep(800);
|
|
check('MENU opens Steam context menu', where(), /^menu:Open/);
|
|
press('B'); await sleep(600);
|
|
check('B closes the menu', doc().querySelectorAll('.BasicUIContextMenu').length, 0);
|
|
S.menu = null;
|
|
await refocus();
|
|
n = S.events.length;
|
|
press('B'); await sleep(1000);
|
|
check('B leaves the page', route() !== SELF && since(n).includes('back'), true);
|
|
}
|
|
async function selftest(opts) {
|
|
opts = opts || {};
|
|
const runs = opts.runs || 1;
|
|
const out = { routeBefore: route(), runs: [], ok: true };
|
|
let h = null;
|
|
try {
|
|
h = react.routes.add(SELF, makeSelftestPage(), { exact: true, owner: 'react-lab' });
|
|
S.handles.push(h);
|
|
for (let i = 0; i < runs; i++) {
|
|
const rep = { steps: [], pass: 0, fail: 0, ok: true };
|
|
try { await selftestOnce(rep); } catch (e) { rep.ok = false; rep.fail++; rep.steps.push('ERROR ' + ((e && e.message) || e) + ' [' + navState() + ']'); }
|
|
if (route() === SELF) { react.nav.back(); await sleep(900); }
|
|
out.runs.push({ ok: rep.ok, pass: rep.pass, fail: rep.fail, steps: rep.steps });
|
|
if (!rep.ok) out.ok = false;
|
|
}
|
|
} finally {
|
|
if (S.menu) { try { S.menu.Hide(); } catch (_) { /* closed */ } S.menu = null; }
|
|
if (route() === SELF) { react.nav.go('/library/home', true); await sleep(600); }
|
|
if (h) { h.remove(); S.handles = S.handles.filter((x) => x !== h); }
|
|
await sleep(200);
|
|
}
|
|
out.routeAfter = route();
|
|
out.pageLeft = doc().querySelectorAll('.lgsx-root').length;
|
|
out.routes = react.routes.list();
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
out.summary = out.runs.map((r) => r.pass + '/' + (r.pass + r.fail)).join(', ');
|
|
return out;
|
|
}
|
|
|
|
// ------------------------------------------------------------ spies (RX-7): Steam's launch calls replaced for the step
|
|
function spy(obj, key, label, calls) {
|
|
if (!obj) return false;
|
|
const own = Object.prototype.hasOwnProperty.call(obj, key);
|
|
const orig = obj[key];
|
|
const fake = function lgsSpy() { calls.push({ fn: label, args: [].slice.call(arguments).map((a) => { try { return JSON.parse(JSON.stringify(a)); } catch (_) { return String(a); } }) }); return Promise.resolve(); };
|
|
try { obj[key] = fake; } catch (_) { return false; }
|
|
if (obj[key] !== fake) return false;
|
|
S.spies.push(() => { if (own) obj[key] = orig; else delete obj[key]; });
|
|
return true;
|
|
}
|
|
function unspy() { while (S.spies.length) { try { S.spies.pop()(); } catch (_) { /* gone */ } } }
|
|
|
|
// Export key of a module whose getter returns v, and the local name the module's `t.d(exports, {...})`
|
|
// map gives that key: used to prove which Steam functions the tile menu calls.
|
|
function webpackRequire() {
|
|
let r = null; const sym = Symbol('lgs-react-lab'); const ch = window.webpackChunksteamui;
|
|
ch.push([[sym], {}, (x) => { r = x; }]);
|
|
for (let i = ch.length - 1; i >= 0; i--) if (ch[i] && ch[i][0] && ch[i][0][0] === sym) { ch.splice(i, 1); break; }
|
|
return r;
|
|
}
|
|
// Source text of a module factory (no require, no side effects).
|
|
function srcOf(modId) { try { return Function.prototype.toString.call(webpackRequire().m[modId]); } catch (_) { return ''; } }
|
|
// Webpack id that module `modId` imports under the local alias `alias` (`alias=t(12345)`).
|
|
function importOf(modId, alias) {
|
|
const m = new RegExp('[,;\\s]' + alias.replace(/\$/g, '\\$') + '=\\w\\((\\d+)\\)').exec(srcOf(modId));
|
|
return m ? m[1] : null;
|
|
}
|
|
function localNameOf(modId, v) {
|
|
let ex = null, key = null;
|
|
try {
|
|
const r = webpackRequire();
|
|
ex = r(modId); // already loaded: AppActions was resolved (and required) by react.ready()
|
|
for (const k of Object.keys(ex)) { try { if (ex[k] === v) { key = k; break; } } catch (_) { /* getter */ } }
|
|
if (!key) return { key: null, local: null, src: '' };
|
|
const src = Function.prototype.toString.call(r.m[modId]);
|
|
const m = new RegExp('[{,]' + key.replace(/\$/g, '\\$') + ':\\(\\)=>([A-Za-z_$][\\w$]*)').exec(src);
|
|
return { key, local: m ? m[1] : null, src };
|
|
} catch (e) { return { key, local: null, src: '', error: String(e) }; }
|
|
}
|
|
|
|
// RX-7, gamepad path (review R1 M1): the A button on Steam's DialogButton and on a ui.menu item reaches
|
|
// the action as Steam's own click (HTMLElement.click(): an untrusted PointerEvent 'click', pointerType
|
|
// ''), which must NOT count as a synthetic lab click, while a lab MouseEvent click on the same button
|
|
// still does. Test mode stays on through the whole phase (actions.test(true) and the action logger), and
|
|
// spies make sure nothing reaches Steam's launch calls; the actions only log.
|
|
async function rx7Gamepad(out, pass, A) {
|
|
const P = '/library/lgs/lab/rx7';
|
|
const seen = {};
|
|
let phase = 'buttonA';
|
|
const evInfo = (e) => {
|
|
const ne = e && (e.nativeEvent || e);
|
|
return ne && typeof ne === 'object' ? { type: ne.type, ctor: ne.constructor && ne.constructor.name, trusted: ne.isTrusted, pointerType: ne.pointerType, pointerId: ne.pointerId } : null;
|
|
};
|
|
const record = (key, e) => { seen[key] = { ev: evInfo(e), reasons: A.mode(e).reasons, res: A.launchNonSteam('/usr/bin/true', e) }; };
|
|
const synthetic = (x) => !!x && x.reasons.includes('synthetic pointer event');
|
|
const c = react.c;
|
|
const Page = () => react.jsx(react.ui.Page, { name: 'rx7', className: 'lgsx-root', children: [
|
|
react.ui.style(CSS),
|
|
react.jsx('div', { className: 'lgsx-title', children: 'RX-7 gamepad path' }, 'h'),
|
|
react.jsx(c.DialogButton, { className: 'lgsx-rx7', autoFocus: true, onClick: (e) => record(phase, e), children: 'Action (logged only)' }, 'b'),
|
|
] });
|
|
const routeBefore = route();
|
|
let h = null;
|
|
const calls = [];
|
|
try {
|
|
h = react.routes.add(P, Page, { exact: true, owner: 'react-lab' });
|
|
S.handles.push(h);
|
|
react.nav.go(P); await sleep(1500);
|
|
react.nav.focusRoot(); await sleep(500);
|
|
const btn = () => doc().querySelector('.lgsx-rx7');
|
|
for (let i = 0; i < 4 && !(btn() && btn().classList.contains('gpfocus')); i++) { FNC().DispatchVirtualButtonClick(BTN.DOWN); await sleep(300); }
|
|
pass(!!btn() && btn().classList.contains('gpfocus'), 'gamepad focus on the lab DialogButton (' + where() + ')');
|
|
const M = react.M, inst = react.nav.inst();
|
|
spy(window.SteamClient.Apps, 'LaunchNonSteamApp', 'LaunchNonSteamApp', calls);
|
|
spy(window.SteamClient.Apps, 'RunGame', 'RunGame', calls);
|
|
spy(M.VRMessages && M.VRMessages.SteamVR, 'DashboardDesktopWindowClicked', 'DashboardDesktopWindowClicked', calls);
|
|
spy(inst, 'Navigate', 'Navigate', calls);
|
|
phase = 'buttonA';
|
|
press('A'); await sleep(500);
|
|
const b = seen.buttonA;
|
|
pass(!!b && !synthetic(b) && b.res.mode === 'logged',
|
|
'A on a DialogButton reaches the action as the gamepad path, not a lab click: ' + JSON.stringify(b && b.ev) + ', reasons [' + (b ? b.reasons.join('; ') : 'onClick not called') + '], ' + (b && b.res.mode));
|
|
// A ui.menu item (Steam's MenuItem): onSelected on A.
|
|
S.menu = react.ui.menu([{ label: 'Menu action (logged only)', onSelected: (e) => record('menuA', e) }], btn(), { label: 'RX-7' });
|
|
await sleep(900);
|
|
const wm = where();
|
|
pass(/^menu:Menu action/.test(wm), 'gamepad focus in the ui.menu sheet: ' + wm);
|
|
if (/^menu:/.test(wm)) { press('A'); await sleep(700); }
|
|
const m = seen.menuA;
|
|
pass(!!m && !synthetic(m) && m.res.mode === 'logged',
|
|
'A on a ui.menu item reaches the action as the gamepad path: ' + JSON.stringify(m && m.ev) + ', reasons [' + (m ? m.reasons.join('; ') : 'onSelected not called') + '], ' + (m && m.res.mode));
|
|
if (doc().querySelector('.BasicUIContextMenu') && S.menu) { try { S.menu.Hide(); } catch (_) { /* closed */ } await sleep(400); }
|
|
S.menu = null;
|
|
// A lab click (MouseEvent) on the same button is still a synthetic pointer event.
|
|
phase = 'labClick';
|
|
if (btn()) labClick(btn());
|
|
await sleep(300);
|
|
const k = seen.labClick;
|
|
pass(!!k && synthetic(k) && k.res.mode === 'logged', 'a lab MouseEvent click is still a synthetic pointer event: ' + JSON.stringify(k && k.ev) + ', reasons [' + (k ? k.reasons.join('; ') : 'onClick not called') + ']');
|
|
pass(calls.length === 0, 'gamepad phase: no Steam launch or navigation call reached a spy: ' + JSON.stringify(calls));
|
|
out.gamepad = seen;
|
|
} finally {
|
|
unspy();
|
|
if (S.menu) { try { S.menu.Hide(); } catch (_) { /* closed */ } S.menu = null; }
|
|
if (route() === P) { react.nav.back(); await sleep(900); }
|
|
if (route() === P) { react.nav.go(routeBefore, true); await sleep(600); }
|
|
if (h) { h.remove(); S.handles = S.handles.filter((x) => x !== h); }
|
|
}
|
|
}
|
|
|
|
// A main-window snapshot once gamepad focus has settled (L.snap), with where focus is. Steam's footer
|
|
// legend follows whatever holds focus, which settles at its own pace after a navigation: two shots are
|
|
// compared with the legend only when focus is on the same element in both.
|
|
async function settledSnap(L) {
|
|
react.nav.focusRoot();
|
|
await sleep(700);
|
|
return { snap: L.snap('main'), focus: L.focused('main') || {} };
|
|
}
|
|
function compareSettled(L, A, B) {
|
|
const sameFocus = JSON.stringify(A.focus) === JSON.stringify(B.focus);
|
|
const legend = (el) => /ActionButtonLabel|FooterLegend|%\{Footer/.test(el || '');
|
|
const strip = (x) => { if (sameFocus) return x; const o = {}; for (const k of Object.keys(x)) if (!legend(x[k].el)) o[k] = x[k]; return o; };
|
|
const a = strip(A.snap), b = strip(B.snap);
|
|
const d = L.diff(a, b);
|
|
const keysA = Object.keys(a).length, keysB = Object.keys(b).length;
|
|
return { ok: !d.issues.length && !d.movedCount && keysA === keysB, sameFocus, focusA: A.focus, focusB: B.focus,
|
|
issues: d.issues.slice(0, 6), movedCount: d.movedCount, keysA, keysB,
|
|
txt: d.issues.length + ' issues, ' + d.movedCount + ' moved, ' + keysA + '/' + keysB + ' nodes' + (sameFocus ? '' : ' (focus differed: footer legend left out)') };
|
|
}
|
|
|
|
// ------------------------------------------------------------ the RX runners
|
|
const runners = {
|
|
async 'RX-1'() { return selftest({ runs: 2 }); },
|
|
|
|
async 'RX-2'(L) {
|
|
if (!L) return { ok: false, blocked: 'needs the lab helpers L (run through glass.py js)' };
|
|
const out = { ok: true, steps: [] };
|
|
// Open bar popups (the lab helpers' openThings() reads the same set).
|
|
const bars = () => {
|
|
const i = react.nav.inst();
|
|
let all = [];
|
|
try { all = [...(i.m_setVRDashboardBarPopups || i.VRDashboardBarPopups || [])]; } catch (_) { all = []; }
|
|
return all.filter((h) => { try { return h.BPopupOpen(); } catch (_) { return true; } });
|
|
};
|
|
const closeBars = async () => { for (const p of bars()) { try { p.closePopup(); } catch (_) { /* gone */ } } await sleep(400); };
|
|
const openPlus = async () => {
|
|
await closeBars();
|
|
const sel = '%{AddWindowButton}';
|
|
const el = L.q('bar', sel);
|
|
L.click('bar', sel);
|
|
el.dispatchEvent(new (L.surface('bar').MouseEvent)('mouseenter'));
|
|
await sleep(1000);
|
|
};
|
|
let h = null, calls = 0;
|
|
const summary = (d, a, b) => ({ controls: d.controls, texts: d.texts, issues: d.issues, movedCount: d.movedCount, keysA: Object.keys(a).length, keysB: Object.keys(b).length });
|
|
const same = (x) => x.issues.length === 0 && x.movedCount === 0 && x.keysA === x.keysB;
|
|
try {
|
|
await openPlus();
|
|
const a = L.snap('barpopup');
|
|
const aBar = L.snap('bar');
|
|
await closeBars();
|
|
h = react.patch.byProps('p2.rx2', react.patch.targets.plusButton, (orig) => function rx2NoOp(props, second) { calls++; return orig.call(this, props, second); });
|
|
out.patch = { count: h.count, kinds: h.kinds, live: h.live };
|
|
const callsBefore = calls;
|
|
out.rerender = react.patch.rerender(h);
|
|
await sleep(300);
|
|
out.wrapperCallsAfterRerender = calls - callsBefore;
|
|
await openPlus();
|
|
const b = L.snap('barpopup');
|
|
const bBar = L.snap('bar');
|
|
await closeBars();
|
|
out.diff = summary(L.diff(a, b), a, b);
|
|
out.diffBar = summary(L.diff(aBar, bBar), aBar, bBar);
|
|
out.wrapperCalls = calls;
|
|
out.steps.push((h.count === 1 ? 'PASS' : 'FAIL') + ' one component matched: ' + h.count + ' ' + h.kinds.join() + ' (live fibers ' + h.live + ')');
|
|
out.steps.push((out.rerender.forced >= 1 && out.wrapperCallsAfterRerender > 0 ? 'PASS' : 'FAIL') + ' rerender() shows the patch at once: ' + JSON.stringify(out.rerender) + ', ' + out.wrapperCallsAfterRerender + ' wrapper calls');
|
|
out.steps.push((calls > 0 ? 'PASS' : 'FAIL') + ' patched render ran: ' + calls + ' calls');
|
|
out.steps.push((same(out.diff) ? 'PASS' : 'FAIL') + ' barpopup (OPEN) unchanged against unpatched: ' + out.diff.issues.length + ' issues, ' + out.diff.movedCount + ' moved, ' + out.diff.keysA + '/' + out.diff.keysB + ' nodes');
|
|
out.steps.push((same(out.diffBar) ? 'PASS' : 'FAIL') + ' bar unchanged against unpatched: ' + out.diffBar.issues.length + ' issues, ' + out.diffBar.movedCount + ' moved, ' + out.diffBar.keysA + '/' + out.diffBar.keysB + ' nodes');
|
|
} catch (e) {
|
|
out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
if (h) h.remove();
|
|
await closeBars();
|
|
// Undo the fake hover of the OPEN recipe (IM §9: pointer to 1400, 900).
|
|
try { L.q('bar', '%{AddWindowButton}').dispatchEvent(new (L.surface('bar').MouseEvent)('mouseleave')); } catch (_) { /* gone */ }
|
|
try { if (typeof L.unhover === 'function') await L.unhover(); } catch (_) { /* older lab */ }
|
|
}
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
out.steps.push((out.patchedLeft === 0 ? 'PASS' : 'FAIL') + ' patchedLeft after removal: ' + out.patchedLeft);
|
|
out.ok = out.steps.every((s) => s.startsWith('PASS'));
|
|
return out;
|
|
},
|
|
|
|
async 'RX-3'(L) {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const d = doc();
|
|
let libSel = null; try { libSel = L ? L.sel('%{GamepadLibrary}') : null; } catch (_) { libSel = null; }
|
|
const routeRoot = () => (libSel ? d.querySelector(libSel) : null);
|
|
const routeNodes = () => { const r = routeRoot(); return r ? r.querySelectorAll('*').length : -1; };
|
|
// A poster is named by the app its component renders (fiber props), not by its (empty) text.
|
|
const appOf = (el) => {
|
|
const f = react.fiber.closest(el, (p) => typeof p.appid === 'number' || (p.app && typeof p.app.appid === 'number') || (p.overview && typeof p.overview.appid === 'number'));
|
|
if (!f) return null;
|
|
const p = f.memoizedProps;
|
|
return typeof p.appid === 'number' ? p.appid : (p.app ? p.app.appid : p.overview.appid);
|
|
};
|
|
const focusKey = () => {
|
|
const f = d.querySelector('.gpfocus');
|
|
if (!f) return null;
|
|
const app = appOf(f);
|
|
return { el: f, app, text: app != null ? 'app ' + app : (f.innerText || f.getAttribute('aria-label') || '').trim().slice(0, 40) };
|
|
};
|
|
const describeEl = (el) => (el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.split(/\s+/).slice(0, 3).join('.') : ''));
|
|
let modal = null, cancelled = 0;
|
|
const routeBefore = react.nav.route();
|
|
try {
|
|
react.nav.go('/library/tab/AllGames');
|
|
await sleep(1600);
|
|
react.nav.focusRoot();
|
|
await sleep(500);
|
|
let before = focusKey();
|
|
// Start on a poster (focus may land on the library's tab row first): D-pad down into the grid.
|
|
for (let i = 0; i < 3 && !(before && before.app != null); i++) { FNC().DispatchVirtualButtonClick(BTN.DOWN); await sleep(400); before = focusKey(); }
|
|
out.focusBefore = before && before.text;
|
|
const n0 = routeNodes();
|
|
out.nodesBefore = n0;
|
|
const root0 = routeRoot();
|
|
const els0 = root0 ? [...root0.querySelectorAll('*')] : [];
|
|
const c = react.c, jsx = react.jsx, jsxs = react.jsxs;
|
|
function LabModal(p) {
|
|
const close = () => { cancelled++; if (p.closeModal) p.closeModal(); };
|
|
return jsxs(c.Focusable, { className: 'lgsx-modal', onCancel: close, onCancelActionDescription: react.ui.text('#Button_Back', 'Back'),
|
|
style: { display: 'flex', gap: '24px', padding: '40px', background: 'rgba(30,32,40,.9)', borderRadius: '32px', color: '#fff' },
|
|
children: [
|
|
jsx(c.DialogButton, { className: 'lgsx-m1', autoFocus: true, onClick: () => {}, children: 'One' }, '1'),
|
|
jsx(c.DialogButton, { className: 'lgsx-m2', onClick: () => {}, children: 'Two' }, '2'),
|
|
] });
|
|
}
|
|
modal = await react.ui.modal(jsx(LabModal, {}));
|
|
await sleep(700);
|
|
const shown = !!d.querySelector('.lgsx-modal');
|
|
pass(shown, 'modal rendered in main');
|
|
pass(react.nav.route() === '/library/tab/AllGames', 'route unchanged: ' + react.nav.route());
|
|
const n1 = routeNodes();
|
|
const root1 = routeRoot();
|
|
const gone = els0.filter((e) => !e.isConnected || !root1 || !root1.contains(e));
|
|
const added = root1 ? [...root1.querySelectorAll('*')].filter((e) => !els0.includes(e)) : [];
|
|
out.nodesDuring = n1;
|
|
out.changedDuring = { gone: gone.map(describeEl).slice(0, 8), added: added.map(describeEl).slice(0, 8) };
|
|
pass(!!root0 && root1 === root0, 'the route stays mounted under the modal (same root node)');
|
|
// Steam renders the grid's gamepad fast-scroll overlay only while gamepad focus is inside the grid,
|
|
// and a poster may drop its focus decoration: both leave when focus moves to the modal and come back
|
|
// after. Anything else is a real change. The posters themselves must be the very same nodes.
|
|
let fso = null; try { fso = L ? L.sel('%{FastScrollOverlay}') : null; } catch (_) { fso = null; }
|
|
const focusDependent = (e) => (fso && (e.matches(fso) || !!(e.parentElement && e.parentElement.closest(fso))))
|
|
|| !!(before && before.el && (before.el === e || before.el.contains(e)));
|
|
const onlyFocusDependent = gone.concat(added).every((e) => focusDependent(e));
|
|
pass(n0 > 0 && (n1 === n0 || onlyFocusDependent), 'route DOM node count unchanged: ' + n0 + ' -> ' + n1 + (n1 !== n0 ? ' (the difference is only Steam\'s focus-dependent nodes: ' + onlyFocusDependent + ')' : ''));
|
|
react.nav.focusRoot();
|
|
await sleep(500);
|
|
const f1 = d.querySelector('.gpfocus');
|
|
pass(!!(f1 && f1.closest('.lgsx-modal')), '.gpfocus inside the modal after FocusApplicationRoot(): ' + (f1 ? (f1.innerText || '').trim().slice(0, 20) : 'none') + ' [' + navState() + ']');
|
|
if (f1 && f1.closest('.lgsx-modal')) {
|
|
FNC().DispatchVirtualButtonClick(BTN.B);
|
|
await sleep(800);
|
|
}
|
|
pass(cancelled === 1 && !d.querySelector('.lgsx-modal'), 'B closes it (onCancel ' + cancelled + ', modal ' + (d.querySelector('.lgsx-modal') ? 'still open' : 'gone') + ')');
|
|
let after = focusKey();
|
|
out.restoredByItself = !!after;
|
|
if (!after) { react.nav.focusRoot(); await sleep(500); after = focusKey(); out.steps.push('NOTE focus parked after close; FocusApplicationRoot() (' + navState() + ')'); }
|
|
out.focusAfter = after && after.text;
|
|
pass(!!(before && after && before.app != null && (after.el === before.el || (after.text && after.text === before.text))), 'focus back on the same poster: "' + (before && before.text) + '" -> "' + (after && after.text) + '"');
|
|
pass(out.restoredByItself, 'focus came back by itself within 800 ms (ui.modal gives it back, D-P2-7), no FocusApplicationRoot() needed');
|
|
pass(routeNodes() === n0, 'route DOM node count after close: ' + routeNodes());
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
if (d.querySelector('.lgsx-modal') && modal) { try { modal.Close(); } catch (_) { /* closed */ } }
|
|
// Leave the device on the route we found it on.
|
|
if (routeBefore !== '/library/tab/AllGames' && react.nav.route() === '/library/tab/AllGames') {
|
|
react.nav.back();
|
|
await sleep(900);
|
|
if (react.nav.route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(600); }
|
|
}
|
|
out.routeBefore = routeBefore;
|
|
out.routeAfter = react.nav.route();
|
|
}
|
|
return out;
|
|
},
|
|
|
|
async 'RX-4'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
try {
|
|
const o = await open('ConfirmModal');
|
|
const d = doc();
|
|
const lab = d.querySelector('.lgsx-lab');
|
|
const txt = lab ? lab.innerText : '';
|
|
pass(o.mounted && /Lab fixture/.test(txt), 'ConfirmModal rendered on ' + o.route + ' (' + o.nodes + ' nodes)');
|
|
const c = await close();
|
|
pass(c.routeLeft && c.nodesLeft === 0, 'unmounted: route ' + c.route + ', lab nodes ' + c.nodesLeft);
|
|
pass(!c.routes.added.includes(LAB), 'no route left behind: ' + JSON.stringify(c.routes));
|
|
} catch (e) { out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e)); try { await close(); } catch (_) { /* best effort */ } }
|
|
return out;
|
|
},
|
|
|
|
async 'RX-5'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const st = react.status();
|
|
if (st.routes.length || st.overrides.length || st.patches.length) return { ok: false, blocked: 'react has live routes or patches from other modules; run with only reactLab on', status: st };
|
|
try {
|
|
react.reset();
|
|
react.test.breakFinder('Focusable');
|
|
let threw = null;
|
|
try { react.routes.add('/library/lgs/lab/rx5', () => null); } catch (e) { threw = e.message; }
|
|
pass(!!threw && /cannot find Focusable/.test(threw), 'install throws: ' + threw);
|
|
let threw2 = null;
|
|
try { react.ready(); } catch (e) { threw2 = e.message; }
|
|
pass(!!threw2, 'ready() stays failed (sticky): ' + threw2);
|
|
const s2 = react.status();
|
|
pass(!s2.ready && s2.routes.length === 0 && react.test.countPatchedLeft() === 0, 'nothing patched: ready ' + s2.ready + ', routes ' + s2.routes.length + ', patchedLeft ' + react.test.countPatchedLeft());
|
|
} finally {
|
|
react.test.breakFinder(null);
|
|
react.reset();
|
|
}
|
|
const t0 = performance.now(); react.ready(); const ms = Math.round(performance.now() - t0);
|
|
pass(react.isReady, 'after reset the module is ready again (' + ms + ' ms)');
|
|
return out;
|
|
},
|
|
|
|
async 'RX-6'() {
|
|
const out = { ok: true, steps: [], times: [] };
|
|
const st = react.status();
|
|
if (st.routes.length || st.overrides.length || st.patches.length) return { ok: false, blocked: 'react has live routes or patches from other modules', status: st };
|
|
for (let i = 0; i < 3; i++) {
|
|
react.reset();
|
|
const t0 = performance.now(); react.ready(); out.times.push(Math.round(performance.now() - t0));
|
|
}
|
|
const t1 = performance.now(); react.ready(); const again = Math.round((performance.now() - t1) * 100) / 100;
|
|
out.max = Math.max(...out.times);
|
|
out.cached = again;
|
|
out.steps.push((out.max <= 250 ? 'PASS' : 'FAIL') + ' ready() (the one scan) ' + out.times.join(', ') + ' ms, max ' + out.max + ' <= 250');
|
|
out.steps.push((again < 1 ? 'PASS' : 'FAIL') + ' a second ready() is cached: ' + again + ' ms');
|
|
try {
|
|
const g = window.__LGS_RT;
|
|
const m = g && g.status ? (g.status().modules || []).find((x) => x.name === 'react') : null;
|
|
if (m) out.steps.push('NOTE runtime installMs for react (install only, no scan): ' + m.installMs);
|
|
} catch (_) { /* no runtime */ }
|
|
out.ok = out.steps.every((s) => !s.startsWith('FAIL'));
|
|
return out;
|
|
},
|
|
|
|
async 'RX-7'() {
|
|
const out = { ok: true, steps: [], results: {} };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const A = react.actions;
|
|
const t = rt.test && rt.test.actions;
|
|
let loggerWasOn = false;
|
|
try { loggerWasOn = !!(t && t.enabled && t.enabled()); } catch (_) { loggerWasOn = false; }
|
|
try { if (t && t.enable && !loggerWasOn) t.enable(true, { ttlMs: 60000 }); } catch (_) { /* shim */ }
|
|
A.test(true);
|
|
const mode = A.mode();
|
|
if (mode.mode !== 'test') { A.test(false); return { ok: false, steps: ['FAIL test mode is not on; aborting before any action: ' + JSON.stringify(mode)] }; }
|
|
pass(true, 'test mode on: ' + mode.reasons.join('; '));
|
|
// Identity checks first (before the spies replace anything).
|
|
const inst = react.nav.inst();
|
|
const M = react.M;
|
|
const hL = A.handler('launchNonSteam');
|
|
pass(typeof hL === 'function' && hL === window.SteamClient.Apps.LaunchNonSteamApp, 'launchNonSteam handler is SteamClient.Apps.LaunchNonSteamApp');
|
|
// The "+" popup module (source scan only): its program row calls SteamClient.Apps?.LaunchNonSteamApp
|
|
// and its window row calls <alias>.p.SteamVR.DashboardDesktopWindowClicked, alias = VRMessages' module.
|
|
const st = react.status();
|
|
let plusId = null;
|
|
try {
|
|
const req = webpackRequire();
|
|
for (const id of Object.keys(req.m)) { const s = Function.prototype.toString.call(req.m[id]); if (s.includes('.p.SteamVR.DashboardDesktopWindowClicked(') && s.includes('LaunchNonSteamApp(')) { plusId = id; break; } }
|
|
} catch (_) { plusId = null; }
|
|
const plusSrc = plusId ? srcOf(plusId) : '';
|
|
out.plusModule = plusId;
|
|
pass(/SteamClient\.Apps\??\.LaunchNonSteamApp\(\w+\.strCmdline\)/.test(plusSrc), 'Steam\'s "+" program row calls SteamClient.Apps.LaunchNonSteamApp(strCmdline) (module ' + plusId + ')');
|
|
const am = /([A-Za-z_$][\w$]*)\.p\.SteamVR\.DashboardDesktopWindowClicked\(/.exec(plusSrc);
|
|
const vrImport = am ? importOf(plusId, am[1]) : null;
|
|
pass(!!vrImport && vrImport === st.finders.VRMessages.module, 'Steam\'s "+" window row calls module ' + vrImport + '\'s p.SteamVR.DashboardDesktopWindowClicked; ours is module ' + st.finders.VRMessages.module);
|
|
const hP = A.handler('primary');
|
|
const pa = localNameOf(st.finders.AppActions.module, hP.primaryAction);
|
|
const ra = localNameOf(st.finders.AppActions.module, hP.runAction);
|
|
const gpi = pa.src.indexOf('GetPrimaryActionMenuItem(');
|
|
const body = gpi >= 0 ? pa.src.slice(gpi, gpi + 600) : '';
|
|
pass(!!(pa.local && ra.local && body.includes(pa.local + '(this.props.instance') && body.includes(ra.local + '(')),
|
|
`primary uses the tile menu's own functions: export ${pa.key}=${pa.local} (primary action), ${ra.key}=${ra.local} (run), called by GetPrimaryActionMenuItem`);
|
|
const hD = A.handler('desktopWindow');
|
|
pass(typeof hD === 'function' && hD === M.VRMessages.SteamVR.DashboardDesktopWindowClicked, 'desktopWindow handler is the popup row\'s p.SteamVR.DashboardDesktopWindowClicked (module ' + st.finders.VRMessages.module + ')');
|
|
pass(A.handler('navigate') === inst.Navigate, 'navigate handler is the main window instance\'s Navigate');
|
|
// Spies: even a bug could not launch anything during this step.
|
|
const calls = [];
|
|
const spied = [
|
|
spy(window.SteamClient.Apps, 'LaunchNonSteamApp', 'LaunchNonSteamApp', calls),
|
|
spy(window.SteamClient.Apps, 'RunGame', 'RunGame', calls),
|
|
spy(window.SteamClient.Apps, 'StreamGame', 'StreamGame', calls),
|
|
spy(window.SteamClient.Installs, 'OpenInstallWizard', 'OpenInstallWizard', calls),
|
|
spy(M.VRMessages.SteamVR, 'DashboardDesktopWindowClicked', 'DashboardDesktopWindowClicked', calls),
|
|
spy(inst, 'Navigate', 'Navigate', calls),
|
|
];
|
|
out.spies = spied;
|
|
const routeBefore = react.nav.route();
|
|
const logBefore = t && t.log ? t.log().length : null;
|
|
try {
|
|
// a real program from Steam's own list (never Liquid Glass), a real game, a fake window id
|
|
let prog = null;
|
|
try {
|
|
const raw = await window.SteamClient.Apps.ScanForInstalledNonSteamApps(true);
|
|
prog = react.data.filterPrograms(raw || [], true).find((p) => !p.isLiquidGlass) || null;
|
|
} catch (_) { prog = null; }
|
|
const game = react.data.installedGames({ limit: 1 })[0];
|
|
const r1 = A.launchNonSteam(prog ? prog.cmdline : '/usr/bin/true');
|
|
const r2 = game ? A.primary(game.appid) : null;
|
|
const r3 = A.desktopWindow(424242);
|
|
const r4 = game ? A.navigate('/library/app/' + game.appid) : A.navigate('/library/app/620');
|
|
const fakeClick = { type: 'click', isTrusted: false };
|
|
const r5 = A.navigate('/library/home', {}, fakeClick);
|
|
const r6 = A.primary(999999999);
|
|
out.results = { launchNonSteam: r1, primary: r2, desktopWindow: r3, navigate: r4, untrusted: r5, refused: r6, program: prog && prog.name, game: game && game.name };
|
|
pass(r1.mode === 'logged', 'launchNonSteam logged, not run: ' + r1.mode + ' (' + (prog ? prog.name : 'no program') + ')');
|
|
pass(!!r2 && r2.mode === 'logged' && !!(r2.detail && r2.detail.action), 'primary logged, not run: ' + (r2 && r2.mode) + ', Steam\'s action ' + (r2 && r2.detail && r2.detail.action) + ' (' + (game && game.name) + ')');
|
|
pass(r3.mode === 'logged', 'desktopWindow logged, not run: ' + r3.mode);
|
|
pass(r4.mode === 'logged', 'navigate logged, not run: ' + r4.mode);
|
|
pass(r5.mode === 'logged' && /synthetic pointer event/.test(r5.reason), 'an untrusted click is test mode by itself: ' + r5.reason);
|
|
pass(r6.mode === 'refused', 'no handler -> refused, nothing guessed: ' + r6.reason);
|
|
pass(calls.length === 0, 'no Steam launch call reached a spy: ' + JSON.stringify(calls));
|
|
pass(react.nav.route() === routeBefore, 'route unchanged: ' + react.nav.route());
|
|
if (logBefore !== null) {
|
|
const added = t.log().slice(logBefore).map((e) => e.fn);
|
|
pass(['launchNonSteam', 'primary', 'desktopWindow', 'navigate'].every((f) => added.includes(f)), 'runtime action log has every call: ' + added.join(', '));
|
|
}
|
|
out.reactLog = A.log.slice(-6).map((e) => e.fn + ' ' + e.mode);
|
|
// The gamepad path needs navigation into a lab page: the Navigate spy goes first, and the phase
|
|
// installs its own spies once it is there.
|
|
unspy();
|
|
await rx7Gamepad(out, pass, A);
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
unspy();
|
|
if (S.menu) { try { S.menu.Hide(); } catch (_) { /* closed */ } S.menu = null; }
|
|
A.test(false);
|
|
// Give the step's action logger back as it was (P10 turns it on for every locked step).
|
|
try { if (t && t.enable && !loggerWasOn) t.enable(false); } catch (_) { /* shim */ }
|
|
}
|
|
return out;
|
|
},
|
|
|
|
// ---------------------------------------------------------- beyond the card's RX list: the other builds
|
|
// RX-OV: routes.override on /library/home (element children) and on the achievements route (function
|
|
// children), Steam's page kept inside ours, an override that throws degrades to Steam's page, removal.
|
|
async 'RX-OV'(L) {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const d = doc();
|
|
const routeBefore = route();
|
|
const home = react.Routes.Library.Home();
|
|
const count = () => d.querySelectorAll('*').length;
|
|
// Steam's route transition keeps the old and new page in the DOM for a moment (Home 802 + AllGames 843
|
|
// = 1645 nodes at 13:01): measure only once the count has held for 600 ms (at most 6 s).
|
|
const stableCount = async () => {
|
|
let last = -1, same = 0;
|
|
for (let i = 0; i < 30; i++) { const n = count(); if (n === last) { if (++same >= 3) return n; } else { same = 0; last = n; } await sleep(200); }
|
|
return last;
|
|
};
|
|
const handles = [];
|
|
try {
|
|
react.nav.go(home, true); await sleep(1500);
|
|
const n0 = await stableCount();
|
|
let calls = 0;
|
|
handles.push(react.routes.override(home, (steam, ctx) => { calls++; return react.jsx('div', { className: 'lgsx-ov', 'data-path': ctx.match && ctx.match.path, children: steam }); }));
|
|
await sleep(1200);
|
|
const ov = d.querySelector('.lgsx-ov');
|
|
pass(!!ov && calls > 0, 'override renders at ' + home + ' (' + calls + ' calls, match ' + (ov && ov.getAttribute('data-path')) + ')');
|
|
pass(!!ov && ov.querySelectorAll('*').length > 100, 'Steam Home is kept inside it: ' + (ov ? ov.querySelectorAll('*').length : 0) + ' nodes');
|
|
handles.pop().remove(); await sleep(1200);
|
|
const n1 = await stableCount();
|
|
pass(!d.querySelector('.lgsx-ov') && Math.abs(n1 - n0) <= Math.max(10, n0 * 0.05), 'removed: Steam Home back, ' + n0 + ' -> ' + n1 + ' nodes');
|
|
// a throwing override degrades to Steam's own page
|
|
function Boom() { throw new Error('lab: forced render error'); }
|
|
handles.push(react.routes.override(home, () => react.jsx(Boom, {})));
|
|
await sleep(1200);
|
|
const n2 = await stableCount();
|
|
pass(Math.abs(n2 - n0) <= Math.max(10, n0 * 0.05), 'a throwing override shows Steam Home instead: ' + n2 + ' nodes');
|
|
handles.pop().remove(); await sleep(900);
|
|
// function children (Steam's achievements route): Steam's own children still get the route props
|
|
const game = react.data.installedGames({ limit: 1 })[0];
|
|
if (game) {
|
|
const achPath = '/library/app/:appid/achievements';
|
|
let seen = null;
|
|
handles.push(react.routes.override(achPath, (steam, ctx) => { seen = ctx.match && ctx.match.params; return react.jsx('div', { className: 'lgsx-ov2', children: steam }); }));
|
|
react.nav.go('/library/app/' + game.appid + '/achievements'); await sleep(1800);
|
|
const ov2 = d.querySelector('.lgsx-ov2');
|
|
pass(!!ov2 && !!seen && String(seen.appid) === String(game.appid), 'function-children route: our wrapper with the real match (appid ' + (seen && seen.appid) + '), Steam content ' + (ov2 ? ov2.querySelectorAll('*').length : 0) + ' nodes');
|
|
handles.pop().remove(); await sleep(900);
|
|
pass(!d.querySelector('.lgsx-ov2'), 'achievements override removed');
|
|
react.nav.back(); await sleep(900);
|
|
}
|
|
let threw = null; try { react.routes.override('/library/no-such-route', () => null); } catch (e) { threw = e.message; }
|
|
pass(!!threw, 'override of a path Steam does not declare throws: ' + threw);
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
while (handles.length) { try { handles.pop().remove(); } catch (_) { /* gone */ } }
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.routeBefore = routeBefore; out.routeAfter = route();
|
|
out.status = react.routes.list();
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
pass(out.patchedLeft === 0 && !out.status.overridden.length, 'patchedLeft ' + out.patchedLeft + ', overrides left ' + out.status.overridden.length);
|
|
return out;
|
|
},
|
|
|
|
// RX-HEAL: React remounts the route switch (simulated: the original type is put back on the live
|
|
// fiber); the history listener re-patches before the router renders into our path.
|
|
async 'RX-HEAL'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const routeBefore = route();
|
|
const P = '/library/lgs/lab/heal';
|
|
let h = null;
|
|
try {
|
|
h = react.routes.add(P, () => react.jsx(react.ui.Page, { className: 'lgsx-heal', children: react.jsx('div', { className: 'lgsx-title', children: 'heal' }) }), { exact: true, owner: 'react-lab' });
|
|
const dropped = react.test.dropSwitchPatch();
|
|
pass(dropped > 0 && react.status().patchedLive === 0, 'switch patch dropped (as on a remount): ' + dropped + ' fiber types restored, patchedLive ' + react.status().patchedLive);
|
|
react.nav.go(P); await sleep(1500);
|
|
pass(!!doc().querySelector('.lgsx-heal') && react.status().patchedLive === 1, 'navigation re-patched it and our page rendered (patchedLive ' + react.status().patchedLive + ')');
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
if (route() === P) { react.nav.back(); await sleep(900); }
|
|
if (h) h.remove();
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
pass(out.patchedLeft === 0, 'patchedLeft after removal: ' + out.patchedLeft);
|
|
out.routeAfter = route();
|
|
return out;
|
|
},
|
|
|
|
// RX-FN: patch.byProps on a bare function component (Steam's PagedSettings, SET P-S1): the patch
|
|
// survives the settings page being unmounted and mounted again (element substitution), the page is
|
|
// unchanged by a no-op wrap, and removal restores everything.
|
|
async 'RX-FN'(L) {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const routeBefore = route();
|
|
const sys = react.Routes.Settings.System();
|
|
let h = null, calls = 0;
|
|
try {
|
|
// The unpatched reference comes through the same entry path as the patched shot below (Home, then
|
|
// Settings), so focus and the footer legend start the same way.
|
|
react.nav.go(sys); await sleep(1200);
|
|
react.nav.go('/library/home'); await sleep(1300);
|
|
react.nav.go(sys); await sleep(1500);
|
|
// Steam's footer legend follows whatever holds gamepad focus, which settles at its own pace after
|
|
// a navigation; it is compared only when focus is on the same element in both shots.
|
|
const settle = async () => { react.nav.focusRoot(); await sleep(700); return (L.focused('main') || {}); };
|
|
const fa = L ? await settle() : null;
|
|
const a = L ? L.snap('main') : null;
|
|
h = react.patch.byProps('p2.rxfn', react.patch.targets.pagedSettings, (orig) => function rxfnNoOp(props, r) { calls++; return orig.call(this, props, r); });
|
|
out.patch = { count: h.count, kinds: h.kinds, live: h.live };
|
|
pass(h.count === 1 && h.kinds[0] === 'fn', 'one bare function component matched: ' + h.count + ' ' + h.kinds.join());
|
|
react.nav.go('/library/home'); await sleep(1300);
|
|
const c0 = calls;
|
|
react.nav.go(sys); await sleep(1500);
|
|
pass(calls > c0, 'patched render runs after the page was unmounted and mounted again: ' + (calls - c0) + ' calls');
|
|
const fb = L ? await settle() : null;
|
|
const b = L ? L.snap('main') : null;
|
|
if (a && b) {
|
|
const sameFocus = JSON.stringify(fa) === JSON.stringify(fb);
|
|
const legend = (k) => /ActionButtonLabel|FooterLegend|%\{Footer/.test(k);
|
|
const strip = (x) => { if (sameFocus) return x; const o = {}; for (const k of Object.keys(x)) if (!legend(x[k].el || '')) o[k] = x[k]; return o; };
|
|
const a2 = strip(a), b2 = strip(b);
|
|
const df = L.diff(a2, b2);
|
|
out.diff = { issues: df.issues, movedCount: df.movedCount, keysA: Object.keys(a2).length, keysB: Object.keys(b2).length, sameFocus, focusA: fa, focusB: fb };
|
|
if (!sameFocus) out.steps.push('NOTE gamepad focus settled on different elements (' + JSON.stringify(fa) + ' vs ' + JSON.stringify(fb) + '); the footer legend is left out of the comparison');
|
|
pass(!df.issues.length && !df.movedCount && out.diff.keysA === out.diff.keysB, sys + ' unchanged against unpatched: ' + df.issues.length + ' issues, ' + df.movedCount + ' moved, ' + out.diff.keysA + '/' + out.diff.keysB + ' nodes');
|
|
}
|
|
h.remove(); h = null;
|
|
pass(react.test.countPatchedLeft() === 0, 'removed while mounted: patchedLeft ' + react.test.countPatchedLeft());
|
|
const c1 = calls;
|
|
react.nav.go('/library/home'); await sleep(1200);
|
|
react.nav.go(sys); await sleep(1500);
|
|
pass(calls === c1 && !!doc().querySelector('.Panel'), 'after removal the wrap never runs again (' + (calls - c1) + ' calls) and Settings renders');
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
if (h) { try { h.remove(); } catch (_) { /* gone */ } }
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
out.routeAfter = route();
|
|
return out;
|
|
},
|
|
|
|
// RX-REMOVE: everything at once (a route, an override, a memo patch and a bare function patch), then
|
|
// the module's own removal (what lgs off runs): patchedLeft 0, Steam's element factories restored, a
|
|
// stale /library/lgs/ entry falls back to Steam's library. The module then starts fresh (unscanned).
|
|
async 'RX-REMOVE'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const st0 = react.status();
|
|
const others = st0.routes.filter((p) => !p.startsWith(LAB)).length + st0.overrides.length + st0.patches.length;
|
|
if (others) return { ok: false, blocked: 'react has live routes or patches from other modules; run with only reactLab on', status: st0 };
|
|
const routeBefore = route();
|
|
const P = '/library/lgs/lab/remove';
|
|
try {
|
|
react.ready();
|
|
const M = react.M;
|
|
const jsx0 = M.jsx.jsx, ce0 = M.React.createElement;
|
|
react.routes.add(P, () => react.jsx(react.ui.Page, { className: 'lgsx-rm', children: react.jsx('div', { className: 'lgsx-title', children: 'remove' }) }), { exact: true });
|
|
react.routes.override(react.Routes.Library.Home(), (steam) => steam);
|
|
react.patch.byProps('p2.rm.plus', react.patch.targets.plusButton, (o) => function (p, r) { return o.call(this, p, r); });
|
|
react.nav.go(react.Routes.Settings.System()); await sleep(1500);
|
|
react.patch.byProps('p2.rm.paged', react.patch.targets.pagedSettings, (o) => function (p, r) { return o.call(this, p, r); });
|
|
react.nav.go(P); await sleep(1500);
|
|
const s1 = react.status();
|
|
pass(!!doc().querySelector('.lgsx-rm') && s1.patches.length === 2 && s1.overrides.length === 1 && M.jsx.jsx !== jsx0, 'all live: route rendered, 2 patches, 1 override, element factories hooked');
|
|
const rep = react.test.cycle();
|
|
out.report = rep;
|
|
// Steam's route transition keeps the exiting page in the DOM for a moment (over 1.2 s under load,
|
|
// 13:44): wait for it to go, at most 4 s.
|
|
for (let i = 0; i < 20 && doc().querySelector('.lgsx-rm'); i++) await sleep(200);
|
|
await sleep(400);
|
|
pass(rep.patchedLeft === 0, 'removal report: patchedLeft ' + rep.patchedLeft + ', restored ' + rep.restored + ', route ' + rep.routeBefore + ' -> ' + rep.routeAfter);
|
|
pass(M.jsx.jsx === jsx0 && M.React.createElement === ce0, 'Steam jsx and createElement are the originals again');
|
|
pass(rep.routeAfter !== P && !doc().querySelector('.lgsx-rm'), 'our route was left: ' + route());
|
|
react.nav.go(P); await sleep(1500);
|
|
const libSel = (() => { try { return window.__LGS_LAB ? window.__LGS_LAB.sel('%{GamepadLibrary}') : null; } catch (_) { return null; } })();
|
|
pass(!doc().querySelector('.lgsx-rm') && (!libSel || !!doc().querySelector(libSel)), 'a stale ' + P + ' entry falls back to Steam library (no page of ours)');
|
|
pass(!react.isReady && react.status().switchFibers === null, 'fresh state after removal: not scanned');
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
try { out.report = react.test.cycle(); } catch (_) { /* gone */ }
|
|
} finally {
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.routeAfter = route();
|
|
return out;
|
|
},
|
|
// RX-TARGETS: the named predicates of contract §5.2 not covered above (statusPill on the bar, appButtons
|
|
// on a game page): each matches exactly one component; a no-op wrap leaves the surface unchanged; removal
|
|
// leaves patchedLeft 0. (plusButton: RX-2; pagedSettings: RX-FN.)
|
|
async 'RX-TARGETS'(L) {
|
|
if (!L) return { ok: false, blocked: 'needs the lab helpers L (run through glass.py js)' };
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const same = (a, b) => { const d = L.diff(a, b); return { ok: !d.issues.length && !d.movedCount && Object.keys(a).length === Object.keys(b).length, txt: d.issues.length + ' issues, ' + d.movedCount + ' moved, ' + Object.keys(a).length + '/' + Object.keys(b).length + ' nodes', issues: d.issues.slice(0, 4) }; };
|
|
const routeBefore = route();
|
|
let h1 = null, h2 = null;
|
|
try {
|
|
// statusPill (bar, always mounted)
|
|
let c1 = 0;
|
|
const a1 = L.snap('bar');
|
|
h1 = react.patch.byProps('p2.rxt.pill', react.patch.targets.statusPill, (o) => function rxtPill(p, r) { c1++; return o.call(this, p, r); });
|
|
out.pill = { count: h1.count, kinds: h1.kinds, live: h1.live, rerender: react.patch.rerender(h1) };
|
|
await sleep(400);
|
|
const b1 = L.snap('bar');
|
|
const s1 = same(a1, b1);
|
|
pass(h1.count === 1, 'statusPill matches one component: ' + h1.count + ' ' + h1.kinds.join() + ' (live ' + h1.live + ', rerender ' + JSON.stringify(out.pill.rerender) + ', ' + c1 + ' wrapper calls)');
|
|
pass(s1.ok, 'bar unchanged with the no-op wrap: ' + s1.txt + (s1.ok ? '' : ' ' + JSON.stringify(s1.issues)));
|
|
h1.remove(); h1 = null;
|
|
pass(react.test.countPatchedLeft() === 0, 'statusPill removed: patchedLeft ' + react.test.countPatchedLeft());
|
|
// appButtons (game page)
|
|
const g = react.data.installedGames({ limit: 1 })[0];
|
|
if (!g) { out.steps.push('NOTE no installed game; appButtons skipped'); }
|
|
else {
|
|
const page = '/library/app/' + g.appid;
|
|
let c2 = 0;
|
|
react.nav.go('/library/home'); await sleep(1200);
|
|
react.nav.go(page); await sleep(1800);
|
|
const a2 = await settledSnap(L);
|
|
h2 = react.patch.byProps('p2.rxt.app', react.patch.targets.appButtons, (o) => function rxtApp(p, r) { c2++; return o.call(this, p, r); });
|
|
out.app = { count: h2.count, kinds: h2.kinds };
|
|
react.nav.go('/library/home'); await sleep(1200);
|
|
react.nav.go(page); await sleep(1800);
|
|
const b2 = await settledSnap(L);
|
|
const s2 = compareSettled(L, a2, b2);
|
|
pass(h2.count === 1 && c2 > 0, 'appButtons matches one component: ' + h2.count + ' ' + h2.kinds.join() + ', patched render ran ' + c2 + ' times after a remount');
|
|
pass(s2.ok, page + ' unchanged with the no-op wrap: ' + s2.txt + (s2.ok ? '' : ' ' + JSON.stringify(s2.issues)));
|
|
h2.remove(); h2 = null;
|
|
pass(react.test.countPatchedLeft() === 0, 'appButtons removed: patchedLeft ' + react.test.countPatchedLeft());
|
|
}
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
if (h1) { try { h1.remove(); } catch (_) { /* gone */ } }
|
|
if (h2) { try { h2.remove(); } catch (_) { /* gone */ } }
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.routeAfter = route();
|
|
return out;
|
|
},
|
|
|
|
// RX-OPT (review R1 M2, m5): an optional patch.byProps that is the module's ONLY registration attaches
|
|
// once its target mounts (the watchers count a pending handle; the late-attach series runs after the
|
|
// navigation commits), for appButtons (forwardRef, game page) and pagedSettings (bare function,
|
|
// Settings). A second layer on appButtons matches although the first layer replaced its render.
|
|
async 'RX-OPT'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const st0 = react.status();
|
|
if (st0.routes.length || st0.overrides.length || st0.patches.length) return { ok: false, blocked: 'react has live routes or patches from other modules; RX-OPT needs the optional patch to be the only registration', status: st0 };
|
|
const g = react.data.installedGames({ limit: 1 })[0];
|
|
if (!g) return { ok: false, blocked: 'no installed game for the game page' };
|
|
const routeBefore = route();
|
|
const page = '/library/app/' + g.appid;
|
|
const sys = react.Routes.Settings.System();
|
|
const pl = (id) => react.status().patches.find((p) => p.id === id) || {};
|
|
// Which runtime and module instance the test ran in (a reload or a module re-install by another agent
|
|
// during the step kills the module's scoped timers).
|
|
const rtState = () => { try { const g = window.__LGS_RT, s = g.status({ log: 80 }); const m = (s.modules || []).find((x) => x.name === 'react') || {};
|
|
return { since: s.since, runtime: s.runtime, react: m.state, warn: (s.log || []).filter((e) => e.mod === 'react' && e.level !== 'info').slice(-3).map((e) => e.msg) }; } catch (e) { return String(e); } };
|
|
out.runtimeAtStart = rtState();
|
|
let h1 = null, h2 = null, h3 = null, c1 = 0, c2 = 0, c3 = 0;
|
|
try {
|
|
react.nav.go('/library/home'); await sleep(1300);
|
|
h1 = react.patch.byProps('p2.opt.app', react.patch.targets.appButtons, (o) => function rxOptApp(p, r) { c1++; return o.call(this, p, r); }, { optional: true });
|
|
const s1 = react.status();
|
|
pass(h1.count === 0 && pl('p2.opt.app').pending === true && s1.watching === true,
|
|
'optional appButtons registered on /library/home as the only registration: count ' + h1.count + ', pending ' + pl('p2.opt.app').pending + ', watching ' + s1.watching + ', late timers ' + s1.lateTimers);
|
|
await sleep(3000);
|
|
const s1b = react.status();
|
|
pass(h1.count === 0 && s1b.watching === true && s1b.lateTimers === 0, 'nothing to attach on Home: still pending and watching after the registration series (late timers ' + s1b.lateTimers + ')');
|
|
react.nav.go(page); await sleep(3000);
|
|
pass(h1.count === 1 && pl('p2.opt.app').pending === false, 'attached after navigating to ' + page + ': count ' + h1.count + ' ' + h1.kinds.join() + ', attached by "' + pl('p2.opt.app').late + '"');
|
|
pass(c1 > 0, 'patched render ran: ' + c1 + ' calls');
|
|
// A second layer on the same component: appButtons reads the row's source through our trampoline.
|
|
let threw = null;
|
|
try { h2 = react.patch.byProps('p2.opt.app2', react.patch.targets.appButtons, (o) => function rxOptApp2(p, r) { c2++; return o.call(this, p, r); }); } catch (e) { threw = e.message; }
|
|
pass(!!h2 && h2.count === 1, 'a second layer on appButtons matches the patched component: ' + (h2 ? h2.count + ' ' + h2.kinds.join() : threw));
|
|
if (h2) {
|
|
const a1 = c1;
|
|
react.nav.go('/library/home'); await sleep(1200);
|
|
react.nav.go(page); await sleep(2000);
|
|
pass(c2 > 0 && c1 > a1, 'both layers run after a remount: layer 1 +' + (c1 - a1) + ', layer 2 ' + c2);
|
|
h2.remove(); h2 = null;
|
|
}
|
|
h1.remove(); h1 = null;
|
|
const s2 = react.status();
|
|
pass(s2.patchedLeft === 0 && s2.watching === false && s2.lateTimers === 0, 'removed: patchedLeft ' + s2.patchedLeft + ', watching ' + s2.watching + ', late timers ' + s2.lateTimers);
|
|
// pagedSettings (a bare function): registered on Home, attached on entering Settings.
|
|
react.nav.go('/library/home'); await sleep(1300);
|
|
h3 = react.patch.byProps('p2.opt.paged', react.patch.targets.pagedSettings, (o) => function rxOptPaged(p, r) { c3++; return o.call(this, p, r); }, { optional: true });
|
|
pass(h3.count === 0 && react.status().watching === true, 'optional pagedSettings registered on Home (only registration): count ' + h3.count + ', watching ' + react.status().watching);
|
|
react.nav.go(sys); await sleep(2500);
|
|
pass(h3.count === 1 && h3.kinds[0] === 'fn', 'attached after entering ' + sys + ': count ' + h3.count + ' ' + h3.kinds.join() + ', attached by "' + pl('p2.opt.paged').late + '"');
|
|
if (!c3) {
|
|
// A bare function shows the patch at its next render: move to another Settings page (plain navigation).
|
|
const f = react.fiber.findAll(react.patch.targets.pagedSettings, { max: 1 })[0];
|
|
const other = f ? (f.memoizedProps.pages || []).find((pg) => pg && pg.visible !== false && typeof pg.route === 'string' && pg.route !== sys) : null;
|
|
if (other) { react.nav.go(other.route, true); await sleep(1500); out.steps.push('NOTE moved to ' + other.route + ' for a render'); }
|
|
}
|
|
pass(c3 > 0, 'patched PagedSettings render ran: ' + c3 + ' calls');
|
|
h3.remove(); h3 = null;
|
|
const s3 = react.status();
|
|
pass(s3.patchedLeft === 0 && s3.watching === false && !s3.jsxHooked, 'removed: patchedLeft ' + s3.patchedLeft + ', watching ' + s3.watching + ', element factories hooked ' + s3.jsxHooked);
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
for (const h of [h2, h1, h3]) if (h) { try { h.remove(); } catch (_) { /* gone */ } }
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.runtimeAtEnd = rtState();
|
|
out.patchedLeft = react.test.countPatchedLeft();
|
|
out.routeAfter = route();
|
|
return out;
|
|
},
|
|
|
|
// RX-RETRY (review R1 m7): a transient ready() failure (no route switch yet, as while Steam's UI mounts
|
|
// after a renderer restart) is retried by ready() itself after a backoff, without reset(); the finders
|
|
// are not scanned again. A missing required finder stays sticky (RX-5).
|
|
async 'RX-RETRY'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const st0 = react.status();
|
|
if (st0.routes.length || st0.overrides.length || st0.patches.length) return { ok: false, blocked: 'react has live routes or patches from other modules; run with only reactLab on', status: st0 };
|
|
try {
|
|
react.reset();
|
|
react.test.noSwitch(true);
|
|
let e1 = null; try { react.ready(); } catch (e) { e1 = e; }
|
|
pass(!!e1 && e1.transient === true && e1.retryAfterMs > 0 && /route switch not found/.test(e1.message),
|
|
'no route switch: ready() throws a transient error: ' + (e1 && e1.message) + ' (retry after ' + (e1 && e1.retryAfterMs) + ' ms)');
|
|
let e2 = null; try { react.ready(); } catch (e) { e2 = e; }
|
|
pass(e2 === e1, 'within the backoff the same error is thrown again, nothing rescanned');
|
|
let e3 = null; try { react.routes.add('/library/lgs/lab/rxretry', () => null); } catch (e) { e3 = e; }
|
|
pass(!!e3 && react.test.countPatchedLeft() === 0 && !react.status().routes.length, 'a caller still fails closed meanwhile: ' + (e3 && e3.message));
|
|
await sleep(e1.retryAfterMs + 150);
|
|
let e4 = null; try { react.ready(); } catch (e) { e4 = e; }
|
|
pass(!!e4 && e4 !== e1 && e4.transient && e4.retryAfterMs === 2 * e1.retryAfterMs, 'still missing after the backoff: retried, next backoff ' + (e4 && e4.retryAfterMs) + ' ms (attempt ' + (e4 && e4.attempt) + ')');
|
|
react.test.noSwitch(false);
|
|
await sleep(e4.retryAfterMs + 150);
|
|
const t0 = performance.now();
|
|
let ok = false; try { ok = react.ready(); } catch (e) { out.steps.push('NOTE ' + e.message); }
|
|
const ms = Math.round(performance.now() - t0);
|
|
const s = react.status();
|
|
pass(ok === true && react.isReady && !s.error, 'once the switch is there, ready() succeeds by itself without reset() in ' + ms + ' ms (finders reused from the first scan)');
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
try { react.test.noSwitch(false); } catch (_) { /* gone */ }
|
|
if (!react.isReady) { try { react.reset(); react.ready(); } catch (_) { /* reported above */ } }
|
|
}
|
|
out.ready = react.isReady;
|
|
return out;
|
|
},
|
|
|
|
// RX-FAIL (review R1 M3, m8): ui.ErrorBoundary's error page as the whole page of a real added route
|
|
// (not the lab wrapper, whose own centring hid the bug): the root spans the page, title and Back sit on
|
|
// the page's centre line inside 48 px insets, the title is Title 2, Back is a focused 60 px capsule
|
|
// with no outline or border, and B leaves.
|
|
async 'RX-FAIL'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
const routeBefore = route();
|
|
try {
|
|
const o = await openRaw('boom', { settleMs: 1500 });
|
|
// Back takes focus on entry (autoFocus). Steam draws .gpfocus only while the navigation source is
|
|
// the gamepad: after another agent's laser step it is not, so DOM focus is read first, then a
|
|
// D-pad press (Back is the page's only control; nothing acts) brings the gamepad source back, as
|
|
// RX-1's refocus does (SR §4 vr-null-tree).
|
|
const backEl = () => doc().querySelector('.lgs-react-fail-back');
|
|
out.domFocusOnEntry = !!backEl() && doc().activeElement === backEl();
|
|
for (let i = 0; i < 6 && where() !== 'fail-page'; i++) {
|
|
react.nav.focusRoot(); await sleep(400);
|
|
if (where() === 'none') { FNC().DispatchVirtualButtonClick(BTN.DOWN); await sleep(400); }
|
|
}
|
|
if (where() !== 'fail-page') out.steps.push('NOTE focus after FocusApplicationRoot() and D-pad: ' + where() + ' (' + navState() + ')');
|
|
const d = doc(), w = react.nav.win();
|
|
const fail = d.querySelector('.lgs-react-fail');
|
|
pass(o.route === RAW && !!fail, 'error page shown on the real added route ' + o.route);
|
|
if (fail) {
|
|
const R = (e) => { const q = e.getBoundingClientRect(); return { x: Math.round(q.x), y: Math.round(q.y), w: Math.round(q.width), h: Math.round(q.height) }; };
|
|
const cx = (e) => { const q = e.getBoundingClientRect(); return Math.round(q.x + q.width / 2); };
|
|
const pr = R(fail.parentElement), fr = R(fail);
|
|
const t = d.querySelector('.lgs-react-fail-title'), b = d.querySelector('.lgs-react-fail-back');
|
|
const mid = Math.round(pr.x + pr.w / 2);
|
|
out.rects = { parent: pr, root: fr, title: t ? R(t) : null, back: b ? R(b) : null, window: w.innerWidth };
|
|
pass(Math.abs(fr.w - pr.w) <= 1 && fr.x === pr.x, 'the page root spans its parent: ' + JSON.stringify(fr) + ' in ' + JSON.stringify(pr));
|
|
pass(!!b && Math.abs(cx(b) - mid) <= 2 && (!t || Math.abs(cx(t) - mid) <= 2), 'title and Back on the centre line: title ' + (t ? cx(t) : '-') + ', Back ' + (b ? cx(b) : '-') + ', centre ' + mid + ' (window ' + w.innerWidth + ')');
|
|
pass(!t || R(t).x >= pr.x + 47, 'the title keeps the 48 px inset: x ' + (t ? R(t).x : '-'));
|
|
if (t) {
|
|
const ts = w.getComputedStyle(t);
|
|
pass(ts.fontSize === '30px' && ts.fontWeight === '700', 'title is Title 2: ' + ts.fontSize + ' / ' + ts.fontWeight);
|
|
}
|
|
if (b) {
|
|
const bs = w.getComputedStyle(b), br = R(b);
|
|
out.back = { radius: bs.borderRadius, font: bs.fontSize + '/' + bs.fontWeight, outline: bs.outlineStyle + ' ' + bs.outlineWidth, border: bs.borderTopWidth };
|
|
pass(br.h >= 59 && br.w >= 239 && parseFloat(bs.borderRadius) >= br.h / 2 - 1 && (bs.outlineStyle === 'none' || parseFloat(bs.outlineWidth) === 0) && parseFloat(bs.borderTopWidth) === 0,
|
|
'Back is a capsule with no outline or border: ' + br.w + ' x ' + br.h + ', radius ' + bs.borderRadius + ', ' + out.back.font + ', outline ' + out.back.outline + ', border ' + bs.borderTopWidth);
|
|
pass(b.classList.contains('gpfocus'), 'Back has gamepad focus: ' + where() + ' (DOM focus on Back at entry: ' + out.domFocusOnEntry + ')');
|
|
}
|
|
press('B'); await sleep(1100);
|
|
pass(route() !== RAW, 'B leaves the error page: ' + route());
|
|
}
|
|
} catch (e) {
|
|
out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e));
|
|
} finally {
|
|
try { await close(); } catch (_) { /* best effort */ }
|
|
if (route() !== routeBefore) { react.nav.go(routeBefore, true); await sleep(900); }
|
|
}
|
|
out.routeAfter = route();
|
|
out.routes = react.routes.list();
|
|
return out;
|
|
},
|
|
|
|
// RX-OPEN (review R1 m6): lab.open() reports a fixture that throws as {mounted: false, error}.
|
|
async 'RX-OPEN'() {
|
|
const out = { ok: true, steps: [] };
|
|
const pass = (c, msg) => { out.steps.push((c ? 'PASS ' : 'FAIL ') + msg); if (!c) out.ok = false; };
|
|
try {
|
|
const a = await open('boom');
|
|
pass(a.mounted === false && /forced render error/.test(a.error || ''), 'throwing fixture: ' + JSON.stringify(a));
|
|
const b = await open('buttons');
|
|
pass(b.mounted === true && b.error === null, 'the next fixture on the same route mounts cleanly: ' + JSON.stringify(b));
|
|
const c = await close();
|
|
pass(c.routeLeft && !c.routes.added.includes(LAB), 'closed: ' + JSON.stringify({ route: c.route, routes: c.routes }));
|
|
} catch (e) { out.ok = false; out.steps.push('ERROR ' + ((e && e.message) || e)); try { await close(); } catch (_) { /* best effort */ } }
|
|
return out;
|
|
},
|
|
};
|
|
|
|
async function run(id, L) {
|
|
const fn = runners[id];
|
|
if (!fn) throw new Error('react-lab: no test ' + id);
|
|
const t0 = Date.now();
|
|
const res = await fn(L || window.__LGS_LAB || null);
|
|
res.test = id;
|
|
res.ms = Date.now() - t0;
|
|
res.date = new Date().toISOString();
|
|
return res;
|
|
}
|
|
|
|
RT.define({
|
|
name: 'react-lab',
|
|
deps: ['react'],
|
|
flag: 'reactLab',
|
|
install(scope) {
|
|
rt = scope || RT;
|
|
react = rt.use('react');
|
|
S = newState();
|
|
builtinFixtures();
|
|
const api = {
|
|
fixture(name, factory) { if (typeof factory !== 'function') throw new Error('fixture: factory must be a function'); S.fixtures.set(name, factory); return name; },
|
|
fixtures: () => [...S.fixtures.keys()],
|
|
open, openRaw, close, selftest, run, press, where,
|
|
get events() { return S.events.slice(); },
|
|
tests: Object.keys(runners),
|
|
};
|
|
// Contract §9: also reachable as rt.react.lab while this module is installed.
|
|
try { react.lab = api; } catch (_) { /* frozen */ }
|
|
return api;
|
|
},
|
|
async remove() {
|
|
const st = S;
|
|
if (!st) return { patchedLeft: 0 };
|
|
try { unspy(); } catch (_) { /* none */ }
|
|
if (st.menu) { try { st.menu.Hide(); } catch (_) { /* closed */ } }
|
|
try { if (st.labRoute) st.labRoute.remove(); } catch (_) { /* gone */ }
|
|
try { if (st.rawRoute) st.rawRoute.remove(); } catch (_) { /* gone */ }
|
|
for (const h of st.handles) { try { h.remove(); } catch (_) { /* gone */ } }
|
|
try { if (react && react.lab) delete react.lab; } catch (_) { /* gone */ }
|
|
S = null; react = null; rt = null;
|
|
return { patchedLeft: 0 };
|
|
},
|
|
});
|
|
})();
|