Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mobile.mjs

20.0 KiB, 1 run

created by r2519314175:535, 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// verify_mobile.mjs — the phone shell: chat floor, left drawer, thing-sheet.
2//
3// Drives the real page at a phone viewport (390×844) and checks the paradigm:
4// the rail opens as a left drawer, a stage guest rises as a bottom sheet over
5// the chat (never taking a bar slot), the ask pill forwards to the one
6// composer, closing the sheet returns the guest to the stage, and growing back
7// to desktop reseats everything with the chat still whole.
8//
9// AND THE RULE THE PARADIGM RESTS ON, which nothing checked until 2026-08-12:
10// EVERY PANEL A PHONE CAN BE ASKED FOR ARRIVES ON SCREEN. A phone panel needs
11// either a seat in the bottom bar with a `body[data-mpanel="…"]` rule in
12// `responsive.css`, or a place in `MOBILE_GUESTS` so it rises as a sheet. Graph
13// and Pending had neither: `responsive.css:89` hides every panel and only four
14// rules un-hide anything, so asking for either took the conversation off screen
15// and put nothing in its place — a visible button, a blank screen, and no way
16// back but the hamburger. Pending was the worse of the two, because a worker's
17// consent question opens that panel itself.
18//
19// This file could not see it. It read `dataset.mpanel` twice and both times
20// asserted it was `'ai'`, which is a fact about the floor and says nothing about
21// what the user was shown. The sweep below asks the only question that matters:
22// after asking for a panel, is that panel on the screen, and is anything?
23//
24// It is written over the PANEL SET, not a list, so the next panel added without
25// a destination fails here rather than shipping.
26//
27// node dev/verify_mobile.mjs --break noguest # the sweep fails for graph and
28// # pending: the state before the fix
29// node dev/verify_mobile.mjs # and then, clean
30import fs from 'node:fs';
31import path from 'node:path';
32import { fileURLToPath } from 'node:url';
33import { open } from './harness.mjs';
34
35const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www');
36
37const BREAK = (() => {
38 const i = process.argv.indexOf('--break');
39 return i > 0 ? String(process.argv[i + 1] || '') : '';
40})();
41
42// Each break is a real edit to a real file, served in place of it through
43// `page.route`, and is how that piece behaved before the fix.
44const BREAKS = {
45 // `MOBILE_GUESTS` as it was: the panels added after the rule was written are
46 // not in it, so none of them has anywhere to go on a phone.
47 //
48 // MATCHED BY SHAPE AND NOT BY THE TABLE'S LITERAL TEXT, which is the state
49 // this break was found in on 2026-08-17: it pinned the four lines the table
50 // held when it was written, three panels had been added to it since, the
51 // anchor matched ZERO times, and `--break noguest` hard-exited 2 instead of
52 // going red. A break nobody can run is a check nobody has proved. The table
53 // is edited every time a panel is added, so the anchor must be the thing that
54 // does not change -- its opening line and its closing brace.
55 noguest: [{
56 file: 'js/daimond.js',
57 re: /var MOBILE_GUESTS = \{[\s\S]*?\n\t\};/,
58 with: 'var MOBILE_GUESTS = { web: 1, doc: 1, msg: 1, compose: 1, tools: 1, '
59 + 'spend: 1, term: 1, trash: 1 };',
60 }],
61};
62
63if (BREAK && !BREAKS[BREAK]) {
64 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
65 process.exit(2);
66}
67
68/// The damaged source, or a hard stop. A break whose anchor is not there exactly
69/// once broke nothing, and the run below would prove nothing.
70///
71/// A spec anchors by `find` (literal) or by `re` (shape). The shape form is for
72/// anchors inside something that is edited often -- a table of panels, say --
73/// where pinning the literal text makes the break go stale the next time
74/// somebody adds a line to it, and stale means it aborts rather than fails.
75function damaged(src, spec) {
76 if (spec.re) {
77 const n = (src.match(new RegExp(spec.re.source, spec.re.flags.includes('g')
78 ? spec.re.flags : spec.re.flags + 'g')) || []).length;
79 if (n !== 1) {
80 console.error(`break '${BREAK}': the shape ${spec.re} matches ${n} time(s) in ${spec.file}.`);
81 process.exit(2);
82 }
83 return src.replace(spec.re, spec.with);
84 }
85 const n = src.split(spec.find).length - 1;
86 if (n !== 1) {
87 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}.`);
88 process.exit(2);
89 }
90 return src.replace(spec.find, spec.with);
91}
92
93/// The damaged files, ONE BODY PER FILE.
94///
95/// Every edit a break names for a file goes into the SAME body, in order, and
96/// that one body is what the route serves. A `page.route` per edit spec does not
97/// work and does not say so: Playwright hands a request to the LAST route
98/// registered for its URL, so a two-edit break shipped only its second edit --
99/// and still went red, for half the reason it claims, with nothing to notice it.
100function damagedFiles() {
101 const byFile = new Map();
102 for (const spec of (BREAKS[BREAK] || [])) {
103 const src = byFile.has(spec.file) ? byFile.get(spec.file)
104 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
105 byFile.set(spec.file, damaged(src, spec));
106 }
107 return byFile;
108}
109
110const routeBreaks = async (pg) => {
111 if (!BREAK) return;
112 for (const [file, body] of damagedFiles()) {
113 await pg.route('**/' + file, r => r.fulfill({
114 status: 200, contentType: 'application/javascript', body,
115 }));
116 }
117};
118
119const ok = [], bad = [];
120const check = (name, pass, detail) => {
121 (pass ? ok : bad).push(name);
122 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
123};
124const sleep = ms => new Promise(r => setTimeout(r, ms));
125// A browser-only tab talking to no gateway account gets 401s on /api/* — that is
126// the disconnected state, not a fault. Judge only real script/page errors.
127const realErrs = () => s.errs.filter(e =>
128 !/Failed to load resource|status of 401|status of 4\d\d/.test(e));
129const shot = async (p) => { try { await page.screenshot({ path: p, timeout: 8000 }); } catch (e) { console.log(' (shot skipped: ' + p + ')'); } };
130const raise = async (id) => { // force a clean open even if open-by-default
131 await page.evaluate((x) => { window.DaimondPanels.hide(x); window.DaimondPanels.show(x); }, id);
132 await sleep(450);
133};
134
135const s = await open({ signIn: true, connect: true, name: 'mobiletester', route: routeBreaks });
136const { page } = s;
137
138// Become a phone. matchMedia('(max-width:760px)') flips, and the shell takes over.
139await page.setViewportSize({ width: 390, height: 844 });
140// Headless does not reliably advance CSS transitions, so a measured mid-flight
141// value is meaningless; disable them so every assertion reads the settled state.
142await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
143await sleep(400);
144
145// A chat must exist for the floor to be the conversation.
146await page.evaluate(() => { try { document.getElementById('new-session-btn').click(); } catch (e) {} });
147await sleep(300);
148await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find(x => /^Start$/.test(x.textContent.trim())); if (b) b.click(); });
149await sleep(500);
150
151// ── 1. Boot clean on a phone ───────────────────────────────
152check('boot: no console errors', realErrs().length === 0, realErrs().slice(0, 3).join(' | '));
153check('boot: shell present', await page.evaluate(() => !!window.DaimondSheet && !!window.DaimondShell));
154check('boot: hamburger visible', await page.evaluate(() => {
155 const b = document.getElementById('drawer-btn'); return b && getComputedStyle(b).display !== 'none';
156}));
157// THE BAR IS THE CHIP ROW NOW, and this asked for the four hard-wired
158// destinations it replaced -- Chat, Email, Files, Agents, four of the seventeen
159// panels there are. The count is asserted UP rather than merely changed: what
160// the footer holds must be the whole pinned row, and the four the old bar named
161// must all still be in it, or "replaced the bar" would be a way of losing three
162// of them quietly.
163{
164 const bar = await page.evaluate(() => {
165 const row = document.getElementById('panel-tags');
166 return {
167 inBar: !!(row && row.closest('#mnav')),
168 ids: row ? [...row.querySelectorAll('.ptag[data-panel]')].map(c => c.dataset.panel) : [],
169 labelled: row ? [...row.querySelectorAll('.ptag[data-panel]')].every(c => (c.textContent || '').trim().length > 0) : false,
170 scrolls: row ? row.scrollWidth > row.clientWidth : false,
171 more: row ? (row.dataset.more || '') : '',
172 };
173 });
174 check('boot: the footer is the panel chip row', bar.inBar);
175 check('boot: it holds more than the four the old bar named',
176 bar.ids.length > 4, bar.ids.length + ': ' + bar.ids.join(','));
177 check('boot: and it still holds all four of them',
178 ['ai', 'mail', 'work', 'agents'].every(id => bar.ids.indexOf(id) !== -1), bar.ids.join(','));
179 check('boot: every chip carries its word', bar.labelled);
180 check('boot: the strip scrolls, and says which way there is more of it',
181 bar.scrolls && /^(start|end|both)$/.test(bar.more), 'more=' + bar.more);
182}
183check('boot: floor is the chat', await page.evaluate(() => document.body.dataset.mpanel === 'ai'));
184
185// ── 1b. The strip's chips reach, and say where you are ─────
186// A strip that scrolls is only useful if the chip you press comes to you: the
187// last four panels are off the end of a 390px screen, and a chip you have to
188// find again after every tap is a chip you stop using.
189{
190 await page.evaluate(() => { const r = document.getElementById('panel-tags'); r.scrollLeft = 0; r.dispatchEvent(new Event('scroll')); });
191 await sleep(250);
192 const before = await page.evaluate(() => {
193 const r = document.getElementById('panel-tags');
194 const c = r.querySelector('.ptag[data-panel="social"]');
195 return { left: Math.round(r.scrollLeft), off: c.getBoundingClientRect().right > window.innerWidth };
196 });
197 await page.evaluate(() => document.querySelector('#panel-tags .ptag[data-panel="social"]').click());
198 await sleep(600);
199 const after = await page.evaluate(() => {
200 const r = document.getElementById('panel-tags');
201 const c = r.querySelector('.ptag[data-panel="social"]');
202 const b = c.getBoundingClientRect();
203 return { left: Math.round(r.scrollLeft), onScreen: b.left >= 0 && b.right <= window.innerWidth,
204 lit: [...r.querySelectorAll('.ptag.on')].map(x => x.dataset.panel) };
205 });
206 check('strip: the last chip starts off the end of the screen', before.off);
207 check('strip: pressing it brings it into view', after.onScreen && after.left > before.left,
208 JSON.stringify(after));
209 check('strip: and it is the only chip filled in — where you are, not what is open',
210 after.lit.length === 1 && after.lit[0] === 'social', after.lit.join(','));
211 // Back to the floor, so the sections below start where they expect to.
212 await page.evaluate(() => document.querySelector('#panel-tags .ptag[data-panel="ai"]').click());
213 await sleep(500);
214}
215
216// ── 2. The drawer ──────────────────────────────────────────
217await page.evaluate(() => document.getElementById('drawer-btn').click());
218await sleep(350);
219check('drawer: opens on hamburger', await page.evaluate(() => document.body.classList.contains('drawer-open')));
220check('drawer: rail is on screen', await page.evaluate(() => {
221 const r = document.getElementById('panel-rail'); if (!r) return false;
222 const x = r.getBoundingClientRect().left; return x > -5; // slid in from the left
223}));
224check('drawer: scrim is catching taps', await page.evaluate(() => {
225 const sc = document.getElementById('scrim'); return sc && getComputedStyle(sc).pointerEvents === 'auto';
226}));
227await page.evaluate(() => document.getElementById('scrim').click());
228await sleep(350);
229check('drawer: scrim tap closes it', await page.evaluate(() => !document.body.classList.contains('drawer-open')));
230
231// ── 3. A thing rises as a sheet ────────────────────────────
232await raise('web');
233check('sheet: opens for a stage guest', await page.evaluate(() => window.DaimondSheet.isOpen() && window.DaimondSheet.guest() === 'web'));
234check('sheet: guest moved into the sheet', await page.evaluate(() => {
235 const el = document.getElementById('panel-web');
236 return el && el.closest('#msheet') !== null;
237}));
238check('sheet: floor stays the chat', await page.evaluate(() => document.body.dataset.mpanel === 'ai'));
239check('sheet: raised above peek', await page.evaluate(() => {
240 const h = document.getElementById('msheet').getBoundingClientRect().height;
241 return h > 120; // well above the ~56px peek
242}));
243check('sheet: ask pill on screen at half', await page.evaluate(() => {
244 const pill = document.getElementById('msheet-ask');
245 const r = pill.getBoundingClientRect();
246 return r.height > 0 && r.bottom <= window.innerHeight + 1 && r.top >= 0;
247}));
248check('sheet: web keeps an ask pill', await page.evaluate(() =>
249 !document.getElementById('msheet-ask').classList.contains('hidden')));
250check('sheet: bar still reachable above sheet', await page.evaluate(() => {
251 const bar = document.getElementById('mnav'), sh = document.getElementById('msheet');
252 const bz = +getComputedStyle(bar).zIndex || 0, sz = +getComputedStyle(sh).zIndex || 0;
253 return bz > sz;
254}));
255check('sheet: chat composer hidden while sheet up', await page.evaluate(() => {
256 const bar = document.querySelector('.ai .chat-input-bar');
257 return bar && getComputedStyle(bar).visibility === 'hidden';
258}));
259await shot('shots/mobile-sheet-web.png');
260
261// ── 4. Tools sheet hides the ask pill ──────────────────────
262await raise('tools');
263check('sheet: only one guest up at a time', await page.evaluate(() =>
264 document.getElementById('panel-web').closest('#msheet') === null &&
265 document.getElementById('panel-tools').closest('#msheet') !== null));
266check('sheet: tools has no ask pill', await page.evaluate(() =>
267 document.getElementById('msheet-ask').classList.contains('hidden')));
268
269// ── 5. Close returns the guest to the stage ────────────────
270await page.evaluate(() => document.getElementById('msheet-close').click());
271await sleep(400);
272check('close: sheet down', await page.evaluate(() => !window.DaimondSheet.isOpen() && !document.body.classList.contains('sheet-open')));
273check('close: guest back in the stage', await page.evaluate(() => {
274 const el = document.getElementById('panel-tools');
275 return el && el.closest('#stage') !== null && el.closest('#msheet') === null;
276}));
277check('close: engine marks it closed', await page.evaluate(() => !window.DaimondPanels.isOpen('tools')));
278
279// ── 6. The ask pill forwards to the one composer ───────────
280await raise('web');
281const asked = await page.evaluate(async () => {
282 const before = document.querySelectorAll('.chat-msg-user').length;
283 document.getElementById('msheet-ask-input').value = 'what is this page';
284 document.getElementById('msheet-ask-send').click();
285 await new Promise(r => setTimeout(r, 600));
286 const after = document.querySelectorAll('.chat-msg-user').length;
287 return { before, after, parkedH: document.getElementById('msheet').style.height };
288});
289check('ask: pill posts a user message to the chat', asked.after === asked.before + 1, JSON.stringify(asked));
290
291// ── 6a. Every panel a phone can ask for arrives on screen ──
292//
293// The panel SET is read out of the page, so a panel added later is swept
294// without anybody remembering to add it here. Two questions per panel, and they
295// are different failures: the panel the user asked for is on screen, and SOME
296// panel is — a blank main area is the defect that shipped, and a panel that is
297// merely mis-seated is the one that would replace it.
298//
299// Everything else is closed first. A dock with no free seat refuses to open
300// anything (`DaimondPanels.show` returns early), and a refusal is not the defect
301// under test — it would redden this sweep for a reason that has nothing to do
302// with whether the panel has a phone destination.
303// The PANELS, not the chips that name them: `data-panel` is on both, so the
304// element's own id is what says which is which.
305const panelIds = await page.evaluate(() =>
306 [...document.querySelectorAll('[data-panel]')]
307 .filter(e => e.id === 'panel-' + e.dataset.panel)
308 .map(e => e.dataset.panel)
309 .filter(id => id !== 'rail'));
310check('phone: the page declares its panels', panelIds.length >= 10, panelIds.join(' '));
311
312const missing = [], blank = [];
313for (const id of panelIds) {
314 const seen = await page.evaluate(async (x) => {
315 const P = window.DaimondPanels;
316 [...document.querySelectorAll('[data-panel]')].forEach((e) => {
317 if (e.id !== 'panel-' + e.dataset.panel) return;
318 const p = e.dataset.panel;
319 if (p !== x && p !== 'rail' && p !== 'ai') { try { P.hide(p); } catch (e2) {} }
320 });
321 try { P.hide(x); } catch (e2) {}
322 P.show(x);
323 await new Promise(r => setTimeout(r, 400));
324 const el = document.getElementById('panel-' + x);
325 const b = el ? el.getBoundingClientRect() : { width: 0, height: 0 };
326 // Anything at all in the main area with a real box. The bug shows here as
327 // nothing: `body[data-mpanel="graph"]` matches no un-hide rule, so every
328 // panel in `.main` keeps `display: none !important`.
329 //
330 // NOT `.rail`. `responsive.css:89` hides `.panel:not(.rail)`, so the rail
331 // keeps its box whatever happens and counting it made this a check that
332 // could not fail — it passed against the broken build, which is the one
333 // thing a check must never do.
334 const anyMain = [...document.querySelectorAll('.main .panel:not(.rail)')]
335 .some((p) => p.getBoundingClientRect().height > 20);
336 return { onScreen: b.width > 0 && b.height > 0, anyMain,
337 mpanel: document.body.dataset.mpanel,
338 guest: window.DaimondSheet ? window.DaimondSheet.guest() : null };
339 }, id);
340 if (!seen.onScreen) missing.push(id + ' (mpanel=' + seen.mpanel + ', sheet=' + seen.guest + ')');
341 if (!seen.anyMain) blank.push(id);
342 // The sheet is put away between panels so each one is asked of a clean shell.
343 await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} });
344 await sleep(250);
345}
346check('phone: every panel the user can ask for arrives on screen',
347 missing.length === 0, missing.length ? ('never appeared: ' + missing.join('; ')) : panelIds.length + ' swept');
348check('phone: and none of them leaves the main area blank',
349 blank.length === 0, blank.length ? ('blank screen for: ' + blank.join(', ')) : '');
350await shot('shots/mobile-panel-sweep.png');
351
352// Back to the chat floor for the desktop half below.
353await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} window.DaimondPanels.show('ai'); });
354await sleep(350);
355
356// ── 7. Grow back to desktop: everything reseats ────────────
357await page.setViewportSize({ width: 1500, height: 950 });
358// A real browser fires `resize` on a viewport change; Playwright's
359// setViewportSize does not always, so dispatch it as the browser would.
360await page.evaluate(() => window.dispatchEvent(new Event('resize')));
361await sleep(600);
362console.log(' dbg ' + JSON.stringify(await page.evaluate(() => ({
363 innerW: window.innerWidth,
364 mqMatches: window.matchMedia('(max-width: 760px)').matches,
365 sheetOpen: document.body.classList.contains('sheet-open'),
366 guest: window.DaimondSheet.guest(),
367 webParent: (function () { const e = document.getElementById('panel-web'); return e && e.parentElement ? e.parentElement.id : null; })(),
368 webOpen: window.DaimondPanels.isOpen('web'),
369}))));
370check('desktop: sheet folded away', await page.evaluate(() =>
371 !document.body.classList.contains('sheet-open') && !window.DaimondSheet.isOpen()));
372check('desktop: rail back in the main column', await page.evaluate(() => {
373 const r = document.getElementById('panel-rail');
374 return r && r.closest('#main') !== null && getComputedStyle(r).position !== 'fixed';
375}));
376check('desktop: web guest reseated on the stage', await page.evaluate(() => {
377 const el = document.getElementById('panel-web');
378 return el && el.closest('#stage') !== null;
379}));
380check('desktop: no new console errors', realErrs().length === 0, realErrs().slice(0, 3).join(' | '));
381
382await shot('shots/mobile-desktop-after.png');
383
384console.log(`\n${ok.length} passed, ${bad.length} failed`);
385if (bad.length) console.log('FAILED: ' + bad.join(', '));
386await s.close();
387process.exit(bad.length ? 1 : 0);