Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/matrix.mjs

13.5 KiB, 1 run

created by r2519314175:25, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// matrix.mjs — one launcher for the whole test matrix: engine × device × theme.
2//
3// The three browser ENGINES are the axis that matters. Android Chrome is
4// Chromium; every iOS browser is WebKit (Safari); Firefox is Gecko. Driving all
5// three at real device viewports, in each theme, is as close to "everywhere" as
6// a Linux host reaches on its own. What it CANNOT reproduce is iOS chrome (the
7// address-bar vh dance, safe areas, input zoom, standalone PWA) — that needs a
8// real iPhone or a device cloud. WebKit-on-Linux is the engine, not the phone.
9//
10// Firefox is included only when installed (`npx playwright install firefox`);
11// this host has Chromium and WebKit, so a Gecko cell is skipped with a note
12// rather than failing the run.
13//
14// import { openCell, defaultCells, VIEWS } from './matrix.mjs';
15// for (const cell of defaultCells()) {
16// const s = await openCell(cell);
17// await VIEWS.find(v => v.name === 'spend').setup(s.page);
18// await s.close();
19// }
20import os from 'node:os';
21import path from 'node:path';
22import { pathToFileURL } from 'node:url';
23import { signInAs, connectMock, scratch } from './harness.mjs';
24
25// This host (Ubuntu 25.10) is newer than WebKit's build target, so the launch
26// preflight refuses; the runtime libs are supplied out-of-band (setup-webkit-libs.sh).
27process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1';
28
29const PW = process.env.DAIMOND_PW
30 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
31const { chromium, firefox, webkit, devices } = await import(pathToFileURL(PW).href);
32const CHROME = process.env.DAIMOND_CHROME
33 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
34
35/// The three engines, and whether each is launchable on this host. Chromium is
36/// pinned to the same build the rest of the harness uses; WebKit and Firefox use
37/// their Playwright bundles. `probe` is filled in lazily by `enginesAvailable`.
38export const ENGINES = {
39 chromium: { type: chromium, launch: { executablePath: CHROME } },
40 webkit: { type: webkit, launch: {} },
41 firefox: { type: firefox, launch: {} },
42};
43
44/// The three themes the app ships, exactly as `data-theme` names them.
45export const THEMES = ['dark', 'light', 'lollypop'];
46
47/// The devices worth walking. `engine` is the browser engine FAITHFUL to the real
48/// device — Android is Chromium, iOS is WebKit — so a mobile cell picks the right
49/// one automatically. `desc` is a Playwright device descriptor; `vp` an explicit
50/// desktop viewport. `mobile` marks a touch device, which turns on tap-target
51/// checks and the mobile shell.
52export const DEVICES = {
53 'desktop': { engine: 'chromium', vp: { width: 1500, height: 950 }, mobile: false },
54 'desktop-narrow': { engine: 'chromium', vp: { width: 1024, height: 820 }, mobile: false },
55 'ipad': { engine: 'webkit', desc: 'iPad (gen 7)', mobile: true },
56 'pixel': { engine: 'chromium', desc: 'Pixel 5', mobile: true },
57 'iphone': { engine: 'webkit', desc: 'iPhone 13', mobile: true },
58 'iphone-se': { engine: 'webkit', desc: 'iPhone SE', mobile: true },
59};
60
61/// Which engines can actually launch here, probed once. A missing engine (e.g.
62/// Firefox not installed) is reported so a run says what it skipped rather than
63/// silently narrowing coverage.
64let _avail = null;
65export async function enginesAvailable() {
66 if (_avail) return _avail;
67 _avail = {};
68 for (const name of Object.keys(ENGINES)) {
69 try {
70 const b = await ENGINES[name].type.launch({ ...ENGINES[name].launch, headless: true });
71 await b.close();
72 _avail[name] = true;
73 } catch (e) {
74 _avail[name] = false;
75 }
76 }
77 return _avail;
78}
79
80/// A cell is one (engine, device, theme). The label is stable and filesystem-safe,
81/// so screenshots and findings key off it.
82export function cellLabel(c) { return `${c.device}.${c.engine}.${c.theme}`; }
83
84/// The default matrix: each mobile device on its faithful engine, desktop on
85/// Chromium and (when present) Firefox, across all three themes. Override with
86/// env DAIMOND_DEVICES / DAIMOND_THEMES (comma lists) to narrow a run.
87export function defaultCells() {
88 const devs = (process.env.DAIMOND_DEVICES || 'desktop,pixel,iphone,ipad').split(',').map(s => s.trim()).filter(Boolean);
89 const themes = (process.env.DAIMOND_THEMES || THEMES.join(',')).split(',').map(s => s.trim()).filter(Boolean);
90 const cells = [];
91 for (const device of devs) {
92 const d = DEVICES[device];
93 if (!d) continue;
94 for (const theme of themes) {
95 cells.push({ engine: d.engine, device, theme });
96 // Desktop is also worth a Gecko pass; mobile Gecko is not a real target.
97 if (device === 'desktop') cells.push({ engine: 'firefox', device, theme });
98 }
99 }
100 return cells;
101}
102
103/// Launch one cell: the right engine, the device's viewport/DPR/touch/UA, the
104/// theme pinned before first paint, then (by default) signed in on the mock model.
105/// Returns a driven page plus a collected console-error list, mirroring harness.open.
106export async function openCell(cell, opts = {}) {
107 const { signIn = true, connect = true } = opts;
108 const eng = ENGINES[cell.engine];
109 if (!eng) throw new Error(`unknown engine ${cell.engine}`);
110
111 const d = DEVICES[cell.device] || {};
112 const descriptor = d.desc ? devices[d.desc] : null;
113
114 // Build the context options from the device descriptor. WebKit rejects
115 // `isMobile`, so it is dropped for that engine (as verify_webkit does).
116 const ctxOpts = {};
117 if (descriptor) {
118 Object.assign(ctxOpts, {
119 viewport: descriptor.viewport,
120 userAgent: descriptor.userAgent,
121 deviceScaleFactor: descriptor.deviceScaleFactor,
122 hasTouch: descriptor.hasTouch,
123 });
124 if (cell.engine === 'chromium' && descriptor.isMobile) ctxOpts.isMobile = true;
125 } else if (d.vp) {
126 ctxOpts.viewport = d.vp;
127 }
128 // Pin the theme before any script runs, so the very first paint is themed.
129 ctxOpts.colorScheme = cell.theme === 'dark' ? 'dark' : 'light';
130
131 const profileDir = path.join(scratch('matrix'), `${cellLabel(cell)}-${process.pid}`);
132 const context = await eng.type.launchPersistentContext(profileDir, {
133 ...eng.launch,
134 headless: true,
135 ...ctxOpts,
136 });
137 // Seed the theme in storage so the app's own setTheme agrees with the attribute.
138 await context.addInitScript(t => {
139 try { localStorage.setItem('daimond-theme', t); } catch (e) {}
140 document.documentElement.setAttribute('data-theme', t);
141 }, cell.theme);
142
143 const page = context.pages()[0] || await context.newPage();
144 const errs = [], logs = [];
145 page.on('console', m => { logs.push(`${m.type()}: ${m.text()}`); if (m.type() === 'error') errs.push(m.text()); });
146 page.on('pageerror', e => errs.push(`pageerror: ${e.message}`));
147 page.on('crash', () => errs.push('PAGE CRASHED'));
148
149 const { APP } = await import('./harness.mjs');
150 await page.goto(APP, { waitUntil: 'domcontentloaded' });
151 // Re-assert the theme after load (the app's boot may set its own default).
152 await applyTheme(page, cell.theme);
153
154 const s = { context, page, errs, logs, cell, label: cellLabel(cell) };
155 if (signIn) await signInAs(s, 'mx' + Math.abs(hash(cellLabel(cell))) % 9999);
156 if (signIn && connect) {
157 await connectMock(s);
158 // connectMock leaves the settings form open; toggle it shut so a signed-in
159 // view is the app itself, not the config panel lingering over it.
160 await page.evaluate(() => { const b = document.getElementById('settings-btn'); if (b) b.click(); }).catch(() => {});
161 await page.waitForTimeout(150);
162 }
163 await applyTheme(page, cell.theme);
164 s.close = async () => { try { await context.close(); } catch (e) {} };
165 return s;
166}
167
168/// Set the theme on a live page the way the app does (attribute + storage).
169export async function applyTheme(page, theme) {
170 await page.evaluate(t => {
171 try { localStorage.setItem('daimond-theme', t); } catch (e) {}
172 document.documentElement.setAttribute('data-theme', t);
173 }, theme).catch(() => {});
174}
175
176/// A stable non-crypto hash, for a per-cell identity name.
177function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; }
178
179/// The app states worth exercising, each reached through the app's own surface so
180/// the walk is a real user path, not a poke at private state. `needsAuth` views
181/// are skipped on a locked (signed-out) cell.
182export const VIEWS = [
183 { name: 'locked', needsAuth: false, setup: async () => {} },
184 { name: 'home', needsAuth: true, setup: async () => {} },
185 { name: 'chat', needsAuth: true, setup: async (p) => chatTurn(p) },
186 { name: 'spend', needsAuth: true, setup: async (p) => open(p, () => window.DaimondSpend && window.DaimondSpend.show(), '.spend-sec') },
187 { name: 'pair-qr', needsAuth: true, setup: async (p) => open(p, () => window.DaimondPairing && window.DaimondPairing.showLink(), '.pair-qr,.pair-code') },
188 { name: 'workspace',needsAuth: true, setup: async (p) => open(p, () => window.DaimondPanels && window.DaimondPanels.show('work'), '#panel-work') },
189 { name: 'mail', needsAuth: true, setup: async (p) => open(p, () => window.DaimondPanels && window.DaimondPanels.show('mail'), '#panel-mail') },
190 { name: 'agents', needsAuth: true, setup: async (p) => open(p, () => window.DaimondPanels && window.DaimondPanels.show('agents'), '#panel-agents') },
191 { name: 'tools', needsAuth: true, setup: async (p) => open(p, () => (window.DaimondTools && window.DaimondTools.show()) || (window.DaimondPanels && window.DaimondPanels.show('tools')), '#panel-tools') },
192 { name: 'compose', needsAuth: true, setup: async (p) => open(p, () => window.DaimondPanels && window.DaimondPanels.show('compose'), '#panel-compose') },
193 // The provider form lives in the admin drawer's Models view, reached from
194 // the "Models" status row, with the add-a-provider form folded away when a
195 // provider already exists. Walk that path -- drawer, Models row, unfold --
196 // state-aware at each step, because connectMock may have left the drawer
197 // open and #settings-btn is a toggle: a blind click shot "home" for every
198 // settings cell.
199 // A BUILT form, not a static panel: the add-a-mailbox dialog exercises the
200 // shared buildForm path (labels, inputs, selects) that no static view
201 // contains. Found missing when a label-spacing defect shipped unseen --
202 // forms constructed on demand were absent from this enumeration entirely.
203 { name: 'mail-add', needsAuth: true, setup: async (p) => open(p, async () => {
204 if (window.DaimondPanels) DaimondPanels.show('mail');
205 await new Promise(r => setTimeout(r, 300));
206 const b = document.querySelector('[data-act="mail-add"]');
207 if (b) b.click();
208 }, '.cfg-fieldlabel') },
209 { name: 'settings', needsAuth: true, setup: async (p) => open(p, async () => {
210 const vis = (el) => el && el.offsetParent !== null;
211 const row = document.getElementById('astat-model');
212 if (!vis(row)) { const b = document.getElementById('settings-btn'); if (b) b.click(); }
213 await new Promise(r => setTimeout(r, 250));
214 const row2 = document.getElementById('astat-model');
215 if (vis(row2)) row2.click();
216 await new Promise(r => setTimeout(r, 250));
217 const form = document.getElementById('byok-form');
218 const add = document.getElementById('models-add');
219 if (form && form.style.display === 'none' && vis(add)) add.click();
220 }, '#byok-form') },
221];
222
223/// Drive one real chat turn against the mock model, so the transcript, the
224/// streaming bubble and the cost row are all in a populated (not empty) state.
225async function chatTurn(page) {
226 await page.evaluate(() => { const b = document.getElementById('new-session-btn'); if (b) b.click(); }).catch(() => {});
227 await page.waitForTimeout(300);
228 await page.evaluate(() => { const s = [...document.querySelectorAll('button')].find(x => /^Start$/.test((x.textContent || '').trim())); if (s) s.click(); }).catch(() => {});
229 await page.waitForSelector('#chat-input', { timeout: 4000 }).catch(() => {});
230 await page.evaluate(() => { const i = document.getElementById('chat-input'); if (i) { i.value = 'In one sentence, what is Daimond?'; i.dispatchEvent(new Event('input', { bubbles: true })); } }).catch(() => {});
231 await page.evaluate(() => { const b = document.getElementById('chat-send'); if (b) b.click(); }).catch(() => {});
232 await page.waitForTimeout(3000);
233}
234
235/// Dismiss anything overlaying the app before the next view is set up, so a modal
236/// or dialog from one view (the pairing overlay, an open settings form) does not
237/// bleed into the next screen's capture or audit.
238export async function resetView(page) {
239 await page.evaluate(() => {
240 document.querySelectorAll('.pair-scrim').forEach(e => e.remove()); // pairing dialog
241 // Close every open guest panel, so a view is captured/audited in isolation
242 // rather than stacked on the panels the previous view left open. The rail
243 // (Diamonds/Chats/Admin) and the chat floor stay.
244 document.querySelectorAll('[data-close]').forEach(b => {
245 var t = b.getAttribute('data-close');
246 if (t && t !== 'rail' && b.offsetParent !== null) { try { b.click(); } catch (e) {} }
247 });
248 // Close the settings/admin form if it is showing, back to home.
249 try {
250 var cfg = document.getElementById('cfg-provider');
251 if (cfg && cfg.offsetParent !== null) { var s = document.getElementById('settings-btn'); if (s) s.click(); }
252 } catch (e) {}
253 }).catch(() => {});
254 await page.waitForTimeout(150);
255}
256
257/// Run a setup thunk in the page, then wait briefly for a landmark selector so the
258/// view has painted before it is audited or shot. Never throws — a view that does
259/// not open is caught by the audit/shot as an empty result.
260async function open(page, thunk, landmark) {
261 await page.evaluate(thunk).catch(() => {});
262 await page.waitForSelector(landmark, { timeout: 3000 }).catch(() => {});
263 await page.waitForTimeout(250);
264}