Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_sweep_seen.mjs

20.4 KiB, 1 run

created by r2519314175:711, 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_sweep_seen.mjs — is it on the screen, in the corner, and pressable?
2//
3// The owner exported eighteen defects from an ordinary day of using Daimond and
4// at least half of them were visible to anyone who opened the app and looked.
5// The suite is 290-odd verifier files and it was green throughout, because
6// almost every check in it asserts that something is TRUE rather than that a
7// person can SEE, REACH or PRESS it. A grep proves code exists; a passing
8// assertion proves a value; neither proves anything about a screen.
9//
10// So this drives the real app at four shapes, opens every panel in every zone,
11// and measures four families of property. What each one is here for is a defect
12// that got past everything else:
13//
14// SEEN Social was not a flex column, so `.imp-notes { overflow-y: auto }`
15// had no overflow to engage on. Twenty notes kept, 2606px of list in
16// a 434px panel, NOT ONE note on the screen and nothing to scroll.
17// Measured here at 20 notes, and the check is general: no list may be
18// left with no item inside its panel and no way to scroll to one.
19// ANCHOR the Web panel's closer went to 229px in and 65px down whenever the
20// page title was long, and Social's sat 248px in at every width. Both
21// are asked against the panel's OWN corner. And the second half of
22// the rule, which one snapshot cannot see: THE POSITIONS ARE TAKEN
23// AGAIN in a changed state -- a long title, a revealed chip, a
24// narrower window -- and every control that stayed must not have
25// moved. The owner's own sentence for it is the one encoded: a
26// transient element must never sit in the flow of clickable targets.
27// PRESS `elementFromPoint` at each control's centre. Every click in this
28// suite is forced, so a button under a stale overlay passes them all.
29// SINGULAR four "Daimond Optimiser" tiles where two belong. Nothing counted.
30//
31// The state pairs are driven through the app's own paths where there is one --
32// `DaimondWeb.open` for the title, `DaimondPanels.show` for the dock -- and
33// through the element's own declaration where there is not: `#sync-chip` is
34// built lazily by sync.js on the first status it has to report, so a run with no
35// gateway holds no chip to reveal. See LIMITS at the foot of this header.
36//
37// node dev/verify_sweep_seen.mjs # the sweep
38// node dev/verify_sweep_seen.mjs --quick # desktop and iphone only
39// node dev/verify_sweep_seen.mjs --headed # under a real display, with shots
40// node dev/verify_sweep_seen.mjs --selftest # prove each family can go red
41//
42// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
43// (DAIMOND_MOCK_PORT, default 9099). No gateway.
44//
45// ── LIMITS, WHICH ARE THE POINT OF WRITING THEM DOWN ────────────────────────
46//
47// A sweep that overclaims is worse than a small one that knows its edges, and
48// the failure this file exists to correct is a suite that was green while the
49// app was visibly broken. So:
50//
51// * A CHROMIUM AT 390x844 IS NOT AN iPHONE. It is not iOS Safari, it has no
52// home-indicator inset, its scrollers are not rubber-banded and its fonts
53// are this machine's. Geometry that is wrong here is wrong there; geometry
54// that is right here may still be wrong there.
55// * IT MEASURES WHAT IS DRAWN, NEVER WHAT IT MEANS. An icon nobody recognises,
56// a word in the wrong register, a control that is exactly where it belongs
57// and is the wrong control -- all of them pass every family above. The
58// owner's "the system-prompt button became an unlabelled glyph and I
59// concluded the feature had been deleted" is not reachable from here, and
60// nothing in this file should be read as covering it.
61// * A REVEALED TRANSIENT IS THE ELEMENT'S OWN DECLARATION, not its module's
62// decision to show it. What is proved is that the row cannot hold it without
63// moving; what is not proved is when the app puts it there.
64// * ONE PALETTE, ONE SPACING. dev/verify_sweep_desktop.mjs walks eleven
65// palettes against two spacings for ink; this walks four shapes for
66// geometry, and the two are deliberately not merged.
67
68import fs from 'node:fs';
69import path from 'node:path';
70import { fileURLToPath } from 'node:url';
71import { open, shot, errors } from './harness.mjs';
72import { audit, positions, drifted, transients, reveal, restore,
73 SHAPES, CORNER, seedNotes, showPanels, openPanels } from './sweepkit.mjs';
74
75const HERE = path.dirname(fileURLToPath(import.meta.url));
76const SHOTS = path.join(HERE, 'shots', 'nsweep');
77fs.mkdirSync(SHOTS, { recursive: true });
78
79const QUICK = process.argv.includes('--quick');
80const HEADED = process.argv.includes('--headed');
81const SELFTEST = process.argv.includes('--selftest');
82const shapes = QUICK ? SHAPES.filter((s) => s.name === 'desktop' || s.name === 'iphone') : SHAPES;
83
84const ok = [], bad = [];
85const check = (name, pass, detail) => {
86 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
87 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
88};
89/// Findings printed under a heading, so a red says what a person would see.
90const say = (fs_) => { for (const f of fs_) console.log(` ${f.family} ${f.what} @ ${f.where} — ${f.detail}`); };
91
92// Every panel there is, so nothing is swept only in the arrangement that
93// happens to be saved. The dock takes two at a time, which is the arrangement
94// the Social defect was reported in.
95const STAGE = ['ai', 'web', 'doc', 'tools', 'graph'];
96const DOCK = ['social', 'trash', 'agents', 'work', 'spend', 'pending'];
97
98const s = await open({ name: 'nsweep', headed: HEADED });
99const p = s.page;
100
101// The world the panels are asked about: twenty notes kept, which is the state
102// the Social panel was reported in, and which no fixture in this suite had.
103await seedNotes(p, 20);
104await p.reload({ waitUntil: 'domcontentloaded' });
105const { signInAs } = await import('./harness.mjs');
106await signInAs(s, s.name);
107await p.waitForTimeout(800);
108
109const findings = [];
110
111for (const shape of shapes) {
112 console.log(`\n── ${shape.name} ${shape.width}x${shape.height} ──────────────────────\n`);
113 await p.setViewportSize({ width: shape.width, height: shape.height });
114 await p.waitForTimeout(400);
115
116 // A panel at a time in the stage, and PAIRS in the dock — a dock panel
117 // sharing its column is the case the Social list was measured in, and the
118 // one no other verifier reaches.
119 const rounds = [];
120 for (const id of STAGE) rounds.push({ label: id, show: ['ai', id] });
121 for (let i = 0; i < DOCK.length; i += 2) rounds.push({ label: DOCK.slice(i, i + 2).join('+'), show: DOCK.slice(i, i + 2) });
122
123 for (const r of rounds) {
124 await showPanels(p, r.show);
125 const live = await openPanels(p);
126 // A round whose panels did not open would produce a full set of clean
127 // measurements of whatever WAS open, under this round's name. That is the
128 // silent-skip this file's own header argues against, so it is a failure.
129 const missing = r.show.filter((id) => !live.includes(id));
130 if (missing.length) {
131 check(`${shape.name}/${r.label}: the panels opened`, false, `never opened: ${missing.join(', ')}`);
132 continue;
133 }
134 const all = (await audit(p)).map((f) => ({ ...f, shape: shape.name, round: r.label }));
135 // BEHIND is a count for a person, not a verdict. See the PRESS note in
136 // sweepkit.mjs: a sheet over a panel and a stale overlay over the app are
137 // the same geometry, and this file does not pretend to tell them apart.
138 const found = all.filter((f) => f.family !== 'BEHIND');
139 const behind = all.filter((f) => f.family === 'BEHIND');
140 findings.push(...all);
141 check(`${shape.name}/${r.label}: nothing hidden, stranded, covered or doubled`,
142 found.length === 0, found.length ? `${found.length} finding(s)` : '');
143 if (found.length) say(found);
144 if (behind.length) console.log(` note ${behind.length} control(s) behind another surface`);
145 if (HEADED || shape.name === 'iphone' || r.label === 'social+trash') {
146 await p.screenshot({ path: path.join(SHOTS, `${shape.name}-${r.label}.png`) }).catch(() => {});
147 }
148 }
149}
150
151// ── ANCHOR, the half a snapshot cannot see ────────────────────────────────
152console.log('\n── does it stay there? ───────────────────────────────────\n');
153
154await p.setViewportSize({ width: 1280, height: 800 });
155await showPanels(p, ['ai', 'web']);
156await p.waitForTimeout(500);
157
158// 1. A LONG TITLE. The Web panel's head holds a title and a row of buttons, and
159// an ordinary URL is long. This is the exact state the owner reported.
160{
161 // SCOPED TO THE PANEL WHOSE STATE CHANGED. Opening a page correctly takes the
162 // Web chip out of the header's row, so every chip after it moves and a
163 // document-wide reading would call that drift. The defect was in the head.
164 const before = await positions(p, '#panel-web');
165 const url = await p.evaluate(async () => {
166 const u = location.origin + '/guide/index.html?'
167 + 'a-query-string-of-the-kind-an-ordinary-page-carries=' + 'x'.repeat(120);
168 try { await window.DaimondWeb.open(u); } catch (e) { return ''; }
169 return u;
170 });
171 await p.waitForTimeout(1200);
172 check('the Web panel took a long page title', !!url, url ? '' : 'DaimondWeb.open refused');
173 const after = await positions(p, '#panel-web');
174 const moved = drifted(before, after);
175 check('a long page title moves nothing in the head',
176 moved.length === 0,
177 moved.slice(0, 4).map((m) => `${m.name} ${m.dx > 0 ? '+' : ''}${m.dx},${m.dy > 0 ? '+' : ''}${m.dy}`).join(' '));
178 const corner = (await audit(p)).filter((f) => f.family === 'ANCHOR');
179 check('every closer is still in its own corner under a long title', corner.length === 0);
180 if (corner.length) { say(corner); findings.push(...corner.map((f) => ({ ...f, shape: 'laptop', round: 'long title' }))); }
181 await p.screenshot({ path: path.join(SHOTS, 'longtitle.png') }).catch(() => {});
182}
183
184// 2. A TRANSIENT IN THE FLOW. The owner's rule, and the one this file states as
185// a rule rather than as a measurement: an element the app shows and hides
186// must not displace the controls a person is aiming at.
187{
188 // A ROW OF PERSISTENT CONTROLS, WHEREVER IT IS. This asked `.top-actions` for
189 // its own transients and reported "none in the DOM — nothing was proved here"
190 // when it found none — so a top bar that had been EMPTIED of transients, which
191 // is the strongest form of the fix and the one lane n-chrome took, read as a
192 // failure to look. The sync chip and the held-sweep notice moved into the
193 // rail's status strip, which is a row of buttons too, so both rows are asked.
194 //
195 // AND THE DRIFT IS MEASURED DOCUMENT-WIDE, not in the row that changed. That
196 // is the opposite of the scoping the Web panel needed above, and deliberately:
197 // a long page title is a state change the head is ABOUT, so its own controls
198 // may reflow; a transient appearing is about nothing, and is entitled to move
199 // nothing anywhere.
200 const ROWS = ['.top-actions', '#admin-status'];
201 const ts = [];
202 for (const sel of ROWS) {
203 for (const t of await transients(p, sel)) ts.push({ ...t, row: sel });
204 }
205 check('the app holds transients to test', ts.length > 0,
206 ts.length ? ts.map((t) => `${t.name} (${t.row})`).join(', ')
207 : 'none in either row — nothing was proved here');
208 for (const t of ts) {
209 const before = await positions(p);
210 await reveal(p, t.key, 'Syncing…');
211 await p.waitForTimeout(250);
212 const after = await positions(p);
213 const moved = drifted(before, after);
214 await restore(p, t.key);
215 await p.waitForTimeout(200);
216 // BOTH AXES, because a row of controls in a column moves DOWN. Said as
217 // `dx` alone, the first three findings here read "new-session-btn 0px" —
218 // a defect reported as no movement at all, which is how a real one gets
219 // waved past.
220 const said = (m) => `${m.name} ${m.dx}×${m.dy}px`;
221 const far = Math.max(...moved.map((m) => Math.max(Math.abs(m.dx), Math.abs(m.dy))), 0);
222 check(`showing ${t.name} moves no persistent control`,
223 moved.length === 0,
224 moved.length ? `${moved.length} moved, e.g. ${moved.slice(0, 3).map(said).join(', ')}` : '');
225 if (moved.length) {
226 findings.push({ family: 'ANCHOR', what: 'a transient that displaces persistent controls',
227 where: t.name, detail: `${moved.length} control(s) moved, up to ${far}px`,
228 shape: 'laptop', round: 'transient' });
229 }
230 }
231}
232
233// 3. A NARROWER WINDOW. Everything is entitled to reflow; nothing is entitled to
234// leave its corner while it does.
235{
236 await p.setViewportSize({ width: 900, height: 800 });
237 await p.waitForTimeout(700);
238 const corner = (await audit(p)).filter((f) => f.family === 'ANCHOR' || f.family === 'PRESS');
239 check('narrowing to 900px leaves every closer in its corner and every control pressable',
240 corner.length === 0, corner.length ? `${corner.length} finding(s)` : '');
241 if (corner.length) { say(corner); findings.push(...corner.map((f) => ({ ...f, shape: 'narrow', round: 'reflow' }))); }
242}
243
244// ── The same geometry in the engine an iPhone actually runs ───────────────
245//
246// A Chromium at 390x844 is a rectangle, not a phone, and the paragraph in this
247// file's header says so. WebKit is the engine iOS Safari is built on, so the
248// same audit through it is the closest this machine gets to the device the
249// owner reported these faults from -- and the distance that remains is stated
250// rather than closed: this is WebKit on Linux under Playwright, with this box's
251// fonts, no home-indicator inset and no iOS compositor. It narrows the claim; it
252// does not make it a claim about iOS.
253//
254// It SKIPS LOUDLY where WebKit will not launch, and a skip is never counted as a
255// pass -- dev/run_all.sh's header spends a paragraph on what that costs.
256{
257 console.log('\n── the same audit, in WebKit at iPhone size ───────────────\n');
258 process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1';
259 const { pathToFileURL } = await import('node:url');
260 const os = await import('node:os');
261 const PWPATH = process.env.DAIMOND_PW
262 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
263 const { webkit } = await import(pathToFileURL(PWPATH).href);
264 // A FORWARDED DISPLAY STALLS THE LAUNCH, which is the house note about
265 // WebKit and the reason these two lines are here rather than in the harness:
266 // nothing in this block goes through `open()`.
267 const env = { ...process.env };
268 delete env.DISPLAY;
269 delete env.WAYLAND_DISPLAY;
270 const { scratch, APP } = await import('./harness.mjs');
271 let ctx = null;
272 try {
273 ctx = await webkit.launchPersistentContext(scratch(`wk-nsweep-${process.pid}`), {
274 viewport: { width: 390, height: 844 }, deviceScaleFactor: 3,
275 hasTouch: true, isMobile: true, env, timeout: 45000,
276 });
277 } catch (e) {
278 check('WebKit at iPhone size: the audit ran', false,
279 'SKIPPED — WebKit would not launch: ' + String(e).split('\n')[0]);
280 }
281 if (ctx) {
282 const wp = ctx.pages()[0] || await ctx.newPage();
283 await wp.goto(APP, { waitUntil: 'domcontentloaded' });
284 await signInAs({ page: wp, name: 'nsweepwk' }, 'nsweepwk');
285 await wp.waitForTimeout(2500);
286 await seedNotes(wp, 20);
287 await wp.reload({ waitUntil: 'domcontentloaded' });
288 await signInAs({ page: wp, name: 'nsweepwk' }, 'nsweepwk');
289 await wp.waitForTimeout(2500);
290 for (const round of [['ai'], ['social', 'trash'], ['web']]) {
291 await showPanels(wp, round);
292 const all = (await audit(wp)).map((f) => ({ ...f, shape: 'webkit-iphone', round: round.join('+') }));
293 const found = all.filter((f) => f.family !== 'BEHIND');
294 findings.push(...all);
295 check(`webkit-iphone/${round.join('+')}: nothing hidden, stranded, covered or doubled`,
296 found.length === 0, found.length ? `${found.length} finding(s)` : '');
297 if (found.length) say(found);
298 await wp.screenshot({ path: path.join(SHOTS, `webkit-iphone-${round.join('+')}.png`) }).catch(() => {});
299 }
300 await ctx.close();
301 }
302}
303
304// ── The instrument proves it can go red ───────────────────────────────────
305//
306// A check never seen fail is not evidence. Under --selftest each family is
307// given the defect it was written for -- the real ones, reverted in the page --
308// and must find it.
309if (SELFTEST) {
310 console.log('\n── selftest: each family, against the defect it was written for ──\n');
311 await p.setViewportSize({ width: 1280, height: 800 });
312 await showPanels(p, ['social', 'trash']);
313
314 // SEEN: the Social panel as it was — a block, so the list's `overflow-y:
315 // auto` has nothing to engage on. This is the reverted fix, in one line.
316 await p.addStyleTag({ content: '.social { display: block !important; overflow: visible !important; }' });
317 await p.waitForTimeout(500);
318 const seen = (await audit(p)).filter((f) => f.family === 'SEEN');
319 check('selftest SEEN: a block Social panel is found', seen.length > 0, `${seen.length} finding(s)`);
320 say(seen.slice(0, 3));
321
322 // ANCHOR: the head's controls back inside the wrapping chip group.
323 await p.addStyleTag({ content: '#panel-social .railhead-acts { order: 3 !important; }' });
324 await p.waitForTimeout(400);
325 const anch = (await audit(p)).filter((f) => f.family === 'ANCHOR');
326 check('selftest ANCHOR: a closer out of its corner is found', anch.length > 0, `${anch.length} finding(s)`);
327 say(anch.slice(0, 3));
328
329 // PRESS: a sheet of glass over the app, of the kind a dialog leaves behind.
330 await p.evaluate(() => {
331 const g = document.createElement('div');
332 g.id = 'nsweep-glass';
333 g.style.cssText = 'position:fixed;inset:0;z-index:99999;background:transparent';
334 document.body.appendChild(g);
335 });
336 await p.waitForTimeout(200);
337 // A sheet of glass is a SURFACE, so its findings land in BEHIND -- which is
338 // the honest answer, and what the count is there for. What is proved here is
339 // that the instrument sees it at all: without the hit test nothing in this
340 // suite would, because every click in it is forced.
341 const press = (await audit(p)).filter((f) => f.family === 'PRESS' || f.family === 'BEHIND');
342 check('selftest PRESS: a stale overlay is found', press.length > 10, `${press.length} control(s) under it`);
343 await p.evaluate(() => document.getElementById('nsweep-glass').remove());
344
345 // SINGULAR: a second tile of a name that is already in the rail.
346 const dup = await p.evaluate(() => {
347 const list = document.getElementById('diamond-list');
348 if (!list || !list.firstElementChild) return false;
349 list.appendChild(list.firstElementChild.cloneNode(true));
350 return true;
351 });
352 await p.waitForTimeout(200);
353 const sing = (await audit(p)).filter((f) => f.family === 'SINGULAR');
354 check('selftest SINGULAR: a doubled rail tile is found', dup && sing.length > 0, `${sing.length} finding(s)`);
355 await p.reload({ waitUntil: 'domcontentloaded' });
356 await signInAs(s, s.name);
357}
358
359// ── The report ────────────────────────────────────────────────────────────
360const REPORT = path.join(HERE, 'sweep_seen_report.md');
361const byFamily = {};
362for (const f of findings) (byFamily[f.family] = byFamily[f.family] || []).push(f);
363let md = '# What a person would see\n\n'
364 + `Written by \`dev/verify_sweep_seen.mjs\`, ${new Date().toISOString().slice(0, 10)}, `
365 + `${shapes.map((x) => x.name).join(', ')}.\n\n`;
366for (const fam of Object.keys(byFamily)) {
367 md += `## ${fam}\n\n| where | what | detail | shape | round |\n|---|---|---|---|---|\n`;
368 for (const f of byFamily[fam]) md += `| \`${f.where}\` | ${f.what} | ${f.detail} | ${f.shape || ''} | ${f.round || ''} |\n`;
369 md += '\n';
370}
371if (!findings.length) md += 'Nothing found.\n';
372fs.writeFileSync(REPORT, md);
373console.log(`\nreport: ${REPORT}`);
374
375// A WORLD WITH NO GATEWAY REFUSES `/api` ON PURPOSE, and says so with a 502 --
376// see dev/world.sh, which made that refusal honest rather than letting a
377// stranger's gateway answer. This file needs no gateway, so those 502s are the
378// world working, and counting them would make it red for a reason that has
379// nothing to do with what it measures. Nothing else is filtered.
380const errs = errors(s).filter((e) => !(/status of 502/.test(e) && /\/api\//.test(e)));
381check('nothing threw in the page while it swept', errs.length === 0, errs.slice(0, 2).join(' | '));
382await shot(s, 'nsweep-seen-last');
383await s.close();
384
385console.log(`\n${ok.length} passed, ${bad.length} failed`);
386if (bad.length) { for (const b of bad) console.log(' FAIL ' + b); process.exit(1); }