Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/shot_qc.mjs

19.0 KiB, 1 run

created by r2519314175:175, 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// shot_qc.mjs — the surfaces `dev/sweep.mjs` cannot reach, photographed and measured.
2//
3// The sweep walks the panels and the three `.pop` overlays, and that is most of
4// the app but not the part the user complained about: *"button and chip
5// misalignment and spacing problems in dialogs in the rail"*. A dialog in the
6// rail is not a panel. It is the Admin drawer on one of its six views, the New
7// Diamond box, the tag editor, the settings modal — each behind a click the
8// sweep does not make, and two of them behind a Diamond that has to exist first.
9// So this seeds a real workspace and then opens them one at a time.
10//
11// It also opens the two overlays the sweep reports as "did not open": the panel
12// gallery, whose opener `#panel-more` exists only when the chip row overflows,
13// and the permission-mode pop, whose opener lives in a chat head that is not
14// there until a chat is.
15//
16// Measuring, not only photographing: a row of controls meant to be equal is
17// checked for being equal, to the pixel, because 16px out of 62 is obvious on
18// screen and invisible in a list of rectangles nobody compares.
19//
20// node dev/shot_qc.mjs --out ~/.cache/daimond/qc
21// node dev/shot_qc.mjs --only appearance,admin-models
22// node dev/shot_qc.mjs --cells sharp-dark-1500,warm-light-380
23import fs from 'node:fs';
24import path from 'node:path';
25import { open, errors, scratch } from './harness.mjs';
26
27const argv = process.argv.slice(2);
28const value = (n, d) => { const i = argv.indexOf(n); return (i >= 0 && argv[i + 1]) ? argv[i + 1] : d; };
29const list = (n) => value(n, '').split(',').map((x) => x.trim()).filter(Boolean);
30
31const OUT = value('--out', scratch('qc'));
32const ONLY = list('--only');
33const CELLS_WANTED = list('--cells');
34fs.mkdirSync(OUT, { recursive: true });
35
36const ALL_CELLS = [
37 { tag: 'sharp-dark-1500', skin: 'sharp', theme: 'dark', w: 1500, h: 950 },
38 { tag: 'warm-light-1500', skin: 'warm', theme: 'light', w: 1500, h: 950 },
39 { tag: 'sharp-light-1500', skin: 'sharp', theme: 'light', w: 1500, h: 950 },
40 { tag: 'warm-dark-1500', skin: 'warm', theme: 'dark', w: 1500, h: 950 },
41 { tag: 'sharp-dark-380', skin: 'sharp', theme: 'dark', w: 380, h: 780 },
42 { tag: 'warm-light-380', skin: 'warm', theme: 'light', w: 380, h: 780 },
43];
44const CELLS = CELLS_WANTED.length ? ALL_CELLS.filter((c) => CELLS_WANTED.includes(c.tag)) : ALL_CELLS;
45
46const s = await open({ name: 'qc', connect: true });
47const { page } = s;
48const pause = (ms) => page.waitForTimeout(ms);
49
50/// Put the app back to rest: nothing modal, nothing popped, no drawer.
51async function quieten() {
52 await page.evaluate(() => {
53 document.querySelectorAll('.pop:not([hidden])').forEach((el) => { el.hidden = true; });
54 const pal = document.getElementById('palette'); if (pal) pal.hidden = true;
55 document.querySelectorAll('.modal').forEach((el) => { el.style.display = 'none'; });
56 document.querySelectorAll('.dlg-scrim, .scrim').forEach((el) => el.remove());
57 const close = document.getElementById('admin-close');
58 if (close && close.getClientRects().length) close.click();
59 }).catch(() => {});
60 await pause(180);
61}
62
63// ── Seeding ────────────────────────────────────────────────────────────
64//
65// Three Diamonds, one of them carrying enough tags to overflow its tile, and a
66// chat with a turn in it. Every rail dialog worth photographing needs at least
67// one of the three to exist, and the tag row needs the tags.
68async function seed() {
69 const drawerClose = page.locator('#admin-close');
70 if (await drawerClose.isVisible().catch(() => false)) { await drawerClose.click({ force: true }); await pause(200); }
71 for (const name of ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes']) {
72 await page.click('#new-diamond-btn', { force: true });
73 await page.waitForSelector('.dlg-input', { timeout: 10000 });
74 await page.fill('.dlg-input', name);
75 await page.click('.dlg-ok', { force: true });
76 await pause(700);
77 }
78 // Tags on the first Diamond, through the editor a person uses. Five of them,
79 // because a tile shows two and then "+n" — and the "+n" is a chip whose
80 // alignment against the two beside it is exactly the kind of thing this
81 // sweep is for.
82 await page.$$eval('.diamond-box', (els) => els[0] && els[0].click());
83 await pause(700);
84 await page.evaluate(() => {
85 const b = [...document.querySelectorAll('.crystal-act')].find((x) => /tag/i.test(x.textContent || ''));
86 if (b) b.click();
87 });
88 await page.waitForSelector('.tag-input', { timeout: 8000 }).catch(() => {});
89 for (const tag of ['rust', 'parser', 'work in progress', 'reading', 'deep']) {
90 await page.fill('.tag-input', tag).catch(() => {});
91 await page.keyboard.press('Enter');
92 await pause(450);
93 }
94 await page.reload({ waitUntil: 'domcontentloaded' });
95 await pause(1500);
96 // The reload lands on the passphrase gate again.
97 const pass = await page.$('#id-pass');
98 if (pass && await pass.isVisible()) {
99 await page.fill('#id-pass', 'testpass1234');
100 await page.evaluate(() => document.getElementById('id-primary').click());
101 await page.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {});
102 await pause(800);
103 }
104 // A chat, so the chat head — and the permission chip in it — exists.
105 await quieten();
106 await page.click('#new-session-btn', { force: true }).catch(() => {});
107 await pause(500);
108 const start = page.locator('.tile-start').first();
109 if (await start.count()) await start.click({ force: true });
110 await page.waitForSelector('#chat-input', { state: 'visible', timeout: 10000 }).catch(() => {});
111 await pause(400);
112}
113
114// ── The surfaces ───────────────────────────────────────────────────────
115//
116// `sel` is what gets photographed; `open` is what has to happen first. A
117// surface that will not open on a cell is reported, not silently dropped: an
118// absent picture reads as "clean" to whoever reads the directory.
119const SURFACES = [
120 { id: 'rail', sel: '#panel-rail', mobileDrawer: true,
121 open: async () => { await quieten(); await page.evaluate(() => DaimondPanels.show('rail')); } },
122
123 { id: 'dlg-newdiamond', sel: '.dlg-card',
124 open: async () => {
125 await quieten();
126 await page.evaluate(() => DaimondPanels.show('rail'));
127 await page.click('#new-diamond-btn', { force: true });
128 await page.waitForSelector('.dlg-input', { timeout: 6000 });
129 },
130 after: async () => { await page.keyboard.press('Escape'); await pause(200); } },
131
132 { id: 'crystal', sel: '.crystal-bar',
133 open: async () => {
134 await quieten();
135 await page.evaluate(() => DaimondPanels.show('rail'));
136 await page.$$eval('.diamond-box', (els) => els[0] && els[0].click());
137 await pause(600);
138 } },
139
140 { id: 'tag-editor', sel: '.tag-editor',
141 open: async () => {
142 await quieten();
143 await page.evaluate(() => DaimondPanels.show('rail'));
144 await page.$$eval('.diamond-box', (els) => els[0] && els[0].click());
145 await pause(600);
146 await page.evaluate(() => {
147 const b = [...document.querySelectorAll('.crystal-act')]
148 .find((x) => /tag/i.test(x.textContent || ''));
149 if (b) b.click();
150 });
151 await page.waitForSelector('.tag-editor', { timeout: 6000 });
152 } },
153
154 { id: 'tag-pool', sel: '#diamond-filter',
155 open: async () => {
156 await quieten();
157 await page.evaluate(() => DaimondPanels.show('rail'));
158 await page.click('#diamond-filter .tagf-toggle', { force: true }).catch(() => {});
159 await pause(350);
160 } },
161
162 { id: 'admin-home', sel: '#admin-body', admin: 'settings-btn' },
163 { id: 'admin-models', sel: '#admin-body', admin: 'astat-model' },
164 { id: 'admin-account', sel: '#admin-body', admin: 'astat-account' },
165 { id: 'admin-tools', sel: '#admin-body', admin: 'astat-tools' },
166 { id: 'admin-hand', sel: '#admin-body', admin: 'astat-hand' },
167 { id: 'admin-release', sel: '#admin-body', admin: 'astat-release' },
168 { id: 'admin-pro', sel: '#admin-body', admin: 'astat-pro' },
169
170 { id: 'admin-models-form', sel: '#admin-body',
171 open: async () => {
172 await quieten();
173 await page.evaluate(() => DaimondPanels.show('rail'));
174 await page.click('#astat-model', { force: true });
175 await pause(450);
176 await page.click('#models-add', { force: true }).catch(() => {});
177 await pause(350);
178 } },
179
180 { id: 'settings-modal', sel: '#settings-modal .modal-card',
181 open: async () => {
182 await quieten();
183 await page.evaluate(() => {
184 const m = document.getElementById('settings-modal');
185 if (m) m.style.display = '';
186 });
187 await pause(300);
188 } },
189
190 { id: 'appearance', sel: '#settings-menu',
191 open: async () => {
192 await quieten();
193 await page.click('#settings-menu-btn', { force: true });
194 await pause(320);
195 },
196 after: async () => { await page.keyboard.press('Escape'); await pause(150); } },
197
198 { id: 'gallery', sel: '#panel-gallery',
199 open: async (cell) => {
200 await quieten();
201 // `#panel-more` is drawn only when the chip row cannot hold every chip,
202 // which at 1500px it always can. Narrow the window until the row
203 // overflows — that IS the state the gallery exists for, so it is the
204 // honest one to photograph, not a reach past the UI.
205 await page.setViewportSize({ width: Math.min(cell.w, 1000), height: cell.h });
206 await pause(400);
207 const more = await page.$('#panel-more');
208 if (!more) return false;
209 await more.click({ force: true });
210 await pause(320);
211 return true;
212 },
213 after: async (cell) => {
214 await page.keyboard.press('Escape');
215 await page.setViewportSize({ width: cell.w, height: cell.h });
216 await pause(250);
217 } },
218
219 { id: 'handmode', sel: '#hand-mode-pop',
220 open: async () => {
221 await quieten();
222 await page.evaluate(() => DaimondPanels.show('ai'));
223 await pause(300);
224 const chip = await page.$('#hand-mode-chip');
225 if (!chip) return false;
226 await chip.click({ force: true });
227 await pause(320);
228 return true;
229 },
230 after: async () => { await page.keyboard.press('Escape'); await pause(150); } },
231
232 { id: 'palette', sel: '.pal-box',
233 open: async () => {
234 await quieten();
235 await page.keyboard.press('Control+k');
236 await pause(350);
237 const ok = await page.$eval('#palette', (el) => !el.hidden).catch(() => false);
238 if (!ok) return false;
239 await page.fill('.pal-input', 'sp').catch(() => {});
240 await pause(250);
241 return true;
242 },
243 after: async () => { await page.keyboard.press('Escape'); await pause(150); } },
244];
245
246// ── Measurement: rows that claim to be even ────────────────────────────
247//
248// Photographs catch what is grossly wrong. These catch what is a few pixels
249// wrong, which is the class the user has twice had to point out.
250const EVENNESS = function () {
251 const out = [];
252 const round = (n) => Math.round(n * 100) / 100;
253 // A row of siblings meant to read as one control. If they are unequal, say
254 // by how much, and name the narrowest and the widest so the fix is obvious.
255 const ROWS = [
256 { what: '.seg (dock tiling / spacing)', sel: '.seg', child: 'button' },
257 { what: 'mobile tab bar', sel: '#mnav', child: 'button' },
258 { what: 'chat head chips', sel: '.chat-head-right', child: 'button' },
259 { what: 'crystal actions', sel: '.crystal-bar', child: 'button' },
260 { what: 'dialog actions', sel: '.dlg-actions', child: 'button' },
261 { what: 'mode ladder rungs', sel: '#hand-mode-pop', child: '.mode-row, label' },
262 ];
263 for (const r of ROWS) {
264 document.querySelectorAll(r.sel).forEach((row, n) => {
265 if (row.getClientRects().length === 0) return;
266 const kids = [...row.querySelectorAll(r.child)]
267 .filter((k) => k.getClientRects().length && k.parentElement === row);
268 if (kids.length < 2) return;
269 const ws = kids.map((k) => k.getBoundingClientRect().width);
270 const hs = kids.map((k) => k.getBoundingClientRect().height);
271 const lo = Math.min(...ws), hi = Math.max(...ws);
272 // One row, or two? A wrapped row's widths are meant to differ.
273 const tops = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top)));
274 out.push({
275 row: `${r.what}${n ? ` [${n}]` : ''}`,
276 n: kids.length,
277 lines: tops.size,
278 widths: ws.map(round),
279 heights: hs.map(round),
280 spread: round(hi - lo),
281 narrowest: kids[ws.indexOf(lo)].textContent.trim().slice(0, 16),
282 widest: kids[ws.indexOf(hi)].textContent.trim().slice(0, 16),
283 });
284 });
285 }
286 // Anything wrapping onto a second line inside a header that means to be one
287 // line. A header that wraps leaves a void beside its title, which is the
288 // mobile-sheet complaint exactly.
289 for (const sel of ['.msheet-grab', '.panel-head', '.chead', '.railhead', '.admin-drawer-head', '.pptw-head']) {
290 document.querySelectorAll(sel).forEach((h) => {
291 if (h.getClientRects().length === 0) return;
292 const kids = [...h.children].filter((k) => k.getClientRects().length);
293 if (kids.length < 2) return;
294 // Not "the tops differ" — in any header a 22px button and a 16px
295 // heading sit at different tops by design, and testing for that
296 // reports every header in the app. A header has WRAPPED when the
297 // vertical spread exceeds the tallest child, which no single line
298 // can do.
299 const rs = kids.map((k) => k.getBoundingClientRect());
300 const spreadY = Math.max(...rs.map((r) => r.top)) - Math.min(...rs.map((r) => r.top));
301 const tallest = Math.max(...rs.map((r) => r.height));
302 if (spreadY > tallest * 0.8) {
303 out.push({
304 row: `WRAPPED HEADER ${sel}`,
305 n: kids.length,
306 lines: 2,
307 spreadY: round(spreadY),
308 tallest: round(tallest),
309 widths: rs.map((r) => round(r.width)),
310 heights: rs.map((r) => round(r.height)),
311 text: h.textContent.trim().replace(/\s+/g, ' ').slice(0, 40),
312 });
313 }
314 });
315 }
316 // The two rail lists, side by side: a Diamond tile and a chat tile that do
317 // not start at the same x read as a wobble down the whole rail.
318 const dl = document.querySelector('#diamond-list');
319 const sl = document.querySelector('#session-list');
320 if (dl && sl && dl.getClientRects().length && sl.getClientRects().length) {
321 const d = dl.querySelector('.session-box'), c = sl.querySelector('.session-box');
322 out.push({
323 row: 'RAIL TILE INSET',
324 diamondList: getComputedStyle(dl).padding,
325 sessionList: getComputedStyle(sl).padding,
326 diamondTileLeft: d ? round(d.getBoundingClientRect().left) : null,
327 chatTileLeft: c ? round(c.getBoundingClientRect().left) : null,
328 diamondTileRight: d ? round(d.getBoundingClientRect().right) : null,
329 chatTileRight: c ? round(c.getBoundingClientRect().right) : null,
330 });
331 }
332 return out;
333};
334
335// ── Run ────────────────────────────────────────────────────────────────
336const findings = [];
337let shots = 0, misses = 0;
338
339async function snap(label, sel) {
340 const file = path.join(OUT, label.replace(/[^\w.-]+/g, '_') + '.png');
341 const el = await page.$(sel);
342 if (!el) return null;
343 try {
344 await el.screenshot({ path: file, timeout: 8000, animations: 'disabled' });
345 } catch (e) {
346 try {
347 const box = await el.boundingBox();
348 const vp = page.viewportSize();
349 if (!box) throw e;
350 const clip = {
351 x: Math.max(0, box.x), y: Math.max(0, box.y),
352 width: Math.min(box.width, vp.width - Math.max(0, box.x)),
353 height: Math.min(box.height, vp.height - Math.max(0, box.y)),
354 };
355 await page.screenshot({ path: file, clip, timeout: 8000, animations: 'disabled' });
356 } catch (e2) { console.log(` MISS ${label} — ${(e2.message || e2).toString().split('\n')[0]}`); misses++; return null; }
357 }
358 // The trap this file exists under: `shot()` swallows a failure, so the run
359 // looks clean and the directory is empty. Confirm the bytes.
360 if (!fs.existsSync(file) || fs.statSync(file).size < 200) {
361 console.log(` MISS ${label} — no bytes on disk`); misses++; return null;
362 }
363 shots++;
364 return file;
365}
366
367try {
368 await page.waitForFunction(() => !!(window.DaimondPanels && DaimondPanels.panels), null, { timeout: 20000 });
369 await seed();
370 console.log(`seeded; sweeping ${SURFACES.length} surfaces over ${CELLS.length} cells into ${OUT}\n`);
371
372 for (const cell of CELLS) {
373 console.log(`── ${cell.tag}`);
374 await page.setViewportSize({ width: cell.w, height: cell.h });
375 await page.evaluate((c) => {
376 document.documentElement.setAttribute('data-skin', c.skin);
377 document.documentElement.setAttribute('data-theme', c.theme);
378 document.documentElement.setAttribute('data-tone', c.theme === 'light' ? 'light' : 'dark');
379 document.documentElement.setAttribute('data-ink', c.theme === 'light' ? 'dark' : 'light');
380 }, cell);
381 await pause(300);
382
383 for (const surf of SURFACES) {
384 if (ONLY.length && !ONLY.includes(surf.id)) continue;
385 try {
386 // Below the breakpoint the rail is behind the hamburger. Photograph
387 // the chat instead of the rail and the picture is mislabelled, which
388 // is worse than no picture.
389 if (cell.w < 700 && (surf.mobileDrawer || /^(rail|tag-|crystal|admin-|dlg-)/.test(surf.id))) {
390 await quieten();
391 const btn = await page.$('#drawer-btn');
392 if (btn) { await btn.click({ force: true }); await pause(400); }
393 }
394 let ok = true;
395 if (surf.admin) {
396 await quieten();
397 if (cell.w >= 700) await page.evaluate(() => DaimondPanels.show('rail'));
398 const b = await page.$('#' + surf.admin);
399 if (!b || !(await b.isVisible())) ok = false;
400 else { await b.click({ force: true }); await pause(450); }
401 } else if (surf.open) {
402 const r = await surf.open(cell);
403 if (r === false) ok = false;
404 }
405 if (!ok) { console.log(` skip ${surf.id} — no opener on this cell`); continue; }
406
407 const shown = await page.$eval(surf.sel, (el) => {
408 const r = el.getBoundingClientRect();
409 return r.width > 4 && r.height > 4;
410 }).catch(() => false);
411 if (!shown) { console.log(` skip ${surf.id} — did not show`); continue; }
412
413 const file = await snap(`${cell.tag}--${surf.id}`, surf.sel);
414 const rows = await page.evaluate(EVENNESS);
415 findings.push({ cell: cell.tag, surface: surf.id, shot: file, rows });
416 // Only the rows that MEAN to be even are faulted for unevenness.
417 // The crystal bar's three buttons are sized to their words and are
418 // meant to be; the segmented controls and the tab bar are not.
419 const EVEN = /\.seg|tab bar|dialog actions|ladder/;
420 const bad = rows.filter((r) => (EVEN.test(r.row) && r.spread > 1 && r.lines === 1)
421 || /WRAPPED/.test(r.row));
422 console.log(` ${bad.length ? 'LOOK' : ' ok '} ${surf.id}${bad.length ? ' ' + bad.map((b) => b.row + (b.spread ? ` spread ${b.spread}px` : '')).join('; ') : ''}`);
423 if (surf.after) await surf.after(cell);
424 } catch (e) {
425 console.log(` ERR ${surf.id} — ${(e.message || String(e)).split('\n')[0]}`);
426 }
427 }
428 }
429
430 const jsonPath = path.join(OUT, 'qc.json');
431 fs.writeFileSync(jsonPath, JSON.stringify(findings, null, '\t'));
432 console.log(`\n${shots} shots, ${misses} missed, in ${OUT}\n${jsonPath}`);
433 const errs = errors(s).filter((e) => !/502|Bad Gateway/.test(e));
434 if (errs.length) {
435 console.log(`\n${errs.length} console error(s):`);
436 for (const e of errs.slice(0, 10)) console.log(' ' + String(e).split('\n')[0]);
437 }
438} finally {
439 await s.close();
440}