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 | // } |
| 20 | import os from 'node:os'; |
| 21 | import path from 'node:path'; |
| 22 | import { pathToFileURL } from 'node:url'; |
| 23 | import { 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). |
| 27 | process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1'; |
| 28 | |
| 29 | const PW = process.env.DAIMOND_PW |
| 30 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 31 | const { chromium, firefox, webkit, devices } = await import(pathToFileURL(PW).href); |
| 32 | const 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`. |
| 38 | export 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. |
| 45 | export 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. |
| 52 | export 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. |
| 64 | let _avail = null; |
| 65 | export 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. |
| 82 | export 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. |
| 87 | export 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. |
| 106 | export 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). |
| 169 | export 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. |
| 177 | function 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. |
| 182 | export 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. |
| 225 | async 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. |
| 238 | export 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. |
| 260 | async 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 | } |