@kaydash9999/banker-plugins 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +41 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +137 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +277 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +162 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +526 -0
- package/skills/progress-mon/scripts/pm-config.mjs +341 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +735 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +104 -15
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +159 -155
- package/skills/remains/SKILL.md +1 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +196 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -16,10 +16,16 @@
|
|
|
16
16
|
* Usage:
|
|
17
17
|
* node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5]
|
|
18
18
|
* [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]
|
|
19
|
+
* [--seams] [--slow-clips <ms>]
|
|
20
|
+
* --seams also probe every seam between clips at the first screen size (seamProbe below)
|
|
21
|
+
* --slow-clips hold each clip request this many ms first (a slow network), to catch a poster flicker
|
|
22
|
+
* --seam-reload load the page afresh before each seam, so that every seam meets its clip still on the way
|
|
19
23
|
* --viewports a trailing t marks a touch screen, r reduced motion (default: the six below)
|
|
20
24
|
* --header the site's fixed header (default: the engine's .sw-topbar)
|
|
21
25
|
* --playwright-from-project also look in the current folder's node_modules, before the global npm root (your own project only)
|
|
22
|
-
* Exit 0 when nothing failed, 1
|
|
26
|
+
* Exit 0 when nothing failed and everything was measured, 1 when something failed, 3 when nothing failed
|
|
27
|
+
* but --seams could not measure a poster at some seam (posterUnmeasured: no pass); the report (JSON)
|
|
28
|
+
* goes to --out or stdout.
|
|
23
29
|
*
|
|
24
30
|
* Needs Playwright with Chromium (setup-playwright skill: npx playwright install chromium), found
|
|
25
31
|
* beside this script or in the global npm root, and in the project only when asked.
|
|
@@ -29,6 +35,7 @@ import fs from 'node:fs';
|
|
|
29
35
|
import { createRequire } from 'node:module';
|
|
30
36
|
import os from 'node:os';
|
|
31
37
|
import path from 'node:path';
|
|
38
|
+
import zlib from 'node:zlib';
|
|
32
39
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
40
|
|
|
34
41
|
export const VIEWPORTS = [
|
|
@@ -79,19 +86,39 @@ function pixelContrast(textRgb, alpha, bg) {
|
|
|
79
86
|
* image `width` wide. NaN when the rect holds no pixel, so it never passes as a contrast.
|
|
80
87
|
*/
|
|
81
88
|
export function worstContrast(textRgb, px, width, rect, alpha = 1) {
|
|
89
|
+
return worstPixel(textRgb, px, width, rect, alpha).min;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** worstContrast and the pixel that gives it: { min, bg } (bg null when the rect holds no pixel). */
|
|
93
|
+
export function worstPixel(textRgb, px, width, rect, alpha = 1) {
|
|
82
94
|
const seen = new Map();
|
|
83
|
-
let worst = NaN;
|
|
95
|
+
let worst = { min: NaN, bg: null };
|
|
84
96
|
for (let y = rect.y; y < rect.y + rect.height; y++) {
|
|
85
97
|
for (let x = rect.x; x < rect.x + rect.width; x++) {
|
|
86
98
|
const i = (y * width + x) * 4;
|
|
87
99
|
const key = (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
|
|
88
100
|
if (!seen.has(key)) seen.set(key, pixelContrast(textRgb, alpha, [px[i], px[i + 1], px[i + 2]]));
|
|
89
|
-
|
|
101
|
+
if (Number.isNaN(worst.min) || seen.get(key) < worst.min) worst = { min: seen.get(key), bg: [px[i], px[i + 1], px[i + 2]] };
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
return worst;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The lowest panel alpha (0.01 steps) at which text keeps `min` contrast over one captured pixel: the
|
|
109
|
+
* scene pixel under the panel is recovered from the capture (panel colour at `panelAlpha` over it), then
|
|
110
|
+
* covered again at each alpha. null when no alpha reaches `min`.
|
|
111
|
+
*/
|
|
112
|
+
export function alphaNeeded(textRgb, textAlpha, captured, panelRgb, panelAlpha, min) {
|
|
113
|
+
const a = Math.min(0.99, Math.max(0, panelAlpha));
|
|
114
|
+
const scene = captured.map((c, k) => Math.min(255, Math.max(0, (c - a * panelRgb[k]) / (1 - a))));
|
|
115
|
+
for (let n = 0; n <= 100; n++) {
|
|
116
|
+
const bg = scene.map((c, k) => (n / 100) * panelRgb[k] + (1 - n / 100) * c);
|
|
117
|
+
if (pixelContrast(textRgb, textAlpha, bg) >= min) return n / 100;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
/**
|
|
96
123
|
* The contrast of a button's text on its own fill: { ratio }. { ratio: null, why } for a colour it
|
|
97
124
|
* cannot read, or a fill that is not solid (the page shows through): not measured, never passed.
|
|
@@ -108,6 +135,9 @@ export const intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width &
|
|
|
108
135
|
// Layout positions are fractional (81.3 against 81): one pixel of slack.
|
|
109
136
|
export const insideViewport = (r, vw, vh, slack = 1) => r.x >= -slack && r.y >= -slack && r.x + r.width <= vw + slack && r.y + r.height <= vh + slack;
|
|
110
137
|
|
|
138
|
+
// What a failed panel line needs: the lowest --intro-panel-alpha that lifts its worst pixel to the floor.
|
|
139
|
+
const needs = (c) => (c.needAlpha === undefined ? '' : c.needAlpha === null ? ' (no panel alpha reaches it)' : ` (needs --intro-panel-alpha >= ${c.needAlpha})`);
|
|
140
|
+
|
|
111
141
|
/** The failures of one measured scene, in a fixed order. */
|
|
112
142
|
export function judge(s, { minContrast }) {
|
|
113
143
|
const out = [];
|
|
@@ -117,7 +147,7 @@ export function judge(s, { minContrast }) {
|
|
|
117
147
|
if (s.hOverflow) out.push('horizontal overflow');
|
|
118
148
|
for (const c of s.contrast) {
|
|
119
149
|
if (c.min === null) out.push(`contrast ${c.el} line ${c.line}: not measured (${c.why})`);
|
|
120
|
-
else if (!(c.min >= minContrast)) out.push(`contrast ${c.el} line ${c.line}: ${c.min}`);
|
|
150
|
+
else if (!(c.min >= minContrast)) out.push(`contrast ${c.el} line ${c.line}: ${c.min}${needs(c)}`);
|
|
121
151
|
}
|
|
122
152
|
if (s.clip === 'not shown') out.push('clip not shown');
|
|
123
153
|
return out;
|
|
@@ -166,7 +196,7 @@ export async function loadPlaywright({ fromProject = false, importBeside = () =>
|
|
|
166
196
|
|
|
167
197
|
const navInfo = (page) => page.evaluate(() => {
|
|
168
198
|
const c = window.IntroStepNav && window.IntroStepNav.current;
|
|
169
|
-
return c ? { last: c.lastScene, stops: c.stops(), state: c.state(), end: c.end } : null;
|
|
199
|
+
return c ? { last: c.lastScene, lastStop: c.lastStop ?? c.lastScene, stops: c.stops(), state: c.state(), end: c.end } : null;
|
|
170
200
|
});
|
|
171
201
|
|
|
172
202
|
// Waits until no move runs and the scroll position has held still for 250 ms.
|
|
@@ -318,11 +348,12 @@ export async function screenshot(page, opts, { tries = 3, waitMs = 150 } = {}) {
|
|
|
318
348
|
}
|
|
319
349
|
}
|
|
320
350
|
|
|
321
|
-
async function lineContrast(page, i, panel) {
|
|
351
|
+
async function lineContrast(page, i, panel, minContrast = 4.5, captureMs = 10_000) {
|
|
322
352
|
const clip = { x: Math.max(0, Math.floor(panel.x)), y: Math.max(0, Math.floor(panel.y)), width: Math.ceil(panel.width), height: Math.ceil(panel.height) };
|
|
323
353
|
const lines = await page.evaluate(hideTextLines, { i, parts: TEXT_PARTS });
|
|
354
|
+
const panelBg = await page.evaluate((k) => getComputedStyle(document.querySelectorAll('.sw-copy')[k]).backgroundColor, i);
|
|
324
355
|
await page.waitForTimeout(80);
|
|
325
|
-
const png = await screenshot(page, { type: 'png', clip });
|
|
356
|
+
const png = await screenshot(page, { type: 'png', clip, timeout: captureMs });
|
|
326
357
|
await page.evaluate(showText, { i, parts: TEXT_PARTS });
|
|
327
358
|
const img = await pixels(page.context(), png);
|
|
328
359
|
return lines.map((l) => {
|
|
@@ -332,15 +363,29 @@ async function lineContrast(page, i, panel) {
|
|
|
332
363
|
const rgb = parseColor(l.color);
|
|
333
364
|
if (!rgb) return { el: l.el, line: l.line, min: null, why: `colour ${l.color}` };
|
|
334
365
|
if (rect.width < 1 || rect.height < 1) return { el: l.el, line: l.line, min: null, why: 'line outside the panel' };
|
|
335
|
-
|
|
366
|
+
const w = worstPixel(rgb, img.data, img.width, rect, colorAlpha(l.color));
|
|
367
|
+
return { el: l.el, line: l.line, min: Number(w.min.toFixed(2)), ...panelNeed(l, rgb, w, panelBg, minContrast) };
|
|
336
368
|
});
|
|
337
369
|
}
|
|
338
370
|
|
|
371
|
+
// For a panel line (not a CTA button with its own fill) on a see-through panel: the alpha it needs.
|
|
372
|
+
function panelNeed(l, rgb, w, panelBg, minContrast) {
|
|
373
|
+
const panel = parseColor(panelBg);
|
|
374
|
+
const a = colorAlpha(panelBg);
|
|
375
|
+
if (l.el === 'cta' || !panel || !w.bg || a <= 0 || a >= 1) return {};
|
|
376
|
+
return { needAlpha: alphaNeeded(rgb, colorAlpha(l.color), w.bg, panel, a, minContrast) };
|
|
377
|
+
}
|
|
378
|
+
|
|
339
379
|
async function measureScene(page, i, o) {
|
|
340
380
|
await goToScene(page, i);
|
|
381
|
+
// with step navigation the copy of the scene at its stop fades in (step-nav.css, 0.35 s)
|
|
382
|
+
if (await page.evaluate(() => document.documentElement.classList.contains('isn-on'))) await page.waitForTimeout(450);
|
|
341
383
|
const clip = await clipState(page, i, o.reduce);
|
|
342
384
|
const g = geometry(await page.evaluate(rawGeometry, { i, header: o.header, covers: COVERS }));
|
|
343
|
-
const contrast = o.contrast && g.opacity > 0.5 ? await lineContrast(page, i, g.panel)
|
|
385
|
+
const contrast = o.contrast && g.opacity > 0.5 ? await lineContrast(page, i, g.panel, o.minContrast, o.captureMs).catch((e) => {
|
|
386
|
+
if (!isTimeout(e)) throw e;
|
|
387
|
+
return [{ el: 'panel', line: 0, min: null, why: `screen capture timed out after ${o.captureMs} ms` }]; // not measured: never a pass
|
|
388
|
+
}) : [];
|
|
344
389
|
const scene = { scene: i + 1, clip, ...g, contrast };
|
|
345
390
|
return { ...scene, failures: g.opacity > 0.5 ? judge(scene, o) : ['panel not visible at its stop'] };
|
|
346
391
|
}
|
|
@@ -377,15 +422,21 @@ async function notchWithInertia(page, vp) {
|
|
|
377
422
|
}
|
|
378
423
|
}
|
|
379
424
|
|
|
380
|
-
|
|
381
|
-
|
|
425
|
+
// A wheel notch and an arrow key go half way: two of them make the step from the last scene's end to the body.
|
|
426
|
+
const HALF = new Set(['wheel', 'ArrowDown']);
|
|
427
|
+
async function sendStep(page, vp, how, dir) {
|
|
428
|
+
for (let k = HALF.has(how) ? 2 : 1; k > 0; k--) await send(page, vp, how, dir);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function bodyRoundTrip(page, vp, how, lastStop) {
|
|
432
|
+
await goToScene(page, lastStop);
|
|
382
433
|
await page.waitForTimeout(400);
|
|
383
|
-
await
|
|
434
|
+
await sendStep(page, vp, how, 'down');
|
|
384
435
|
await settle(page);
|
|
385
436
|
const reachesBody = (await navInfo(page)).state.atBody;
|
|
386
|
-
await
|
|
437
|
+
await sendStep(page, vp, how, 'up');
|
|
387
438
|
await settle(page);
|
|
388
|
-
return { reachesBody, upReturns: (await navInfo(page)).state.index ===
|
|
439
|
+
return { reachesBody, upReturns: (await navInfo(page)).state.index === lastStop };
|
|
389
440
|
}
|
|
390
441
|
|
|
391
442
|
const ready = (page) => page.evaluate(() => {
|
|
@@ -397,9 +448,9 @@ const bodyFocused = (page, end) => page.evaluate((sel) => document.activeElement
|
|
|
397
448
|
// The main button: hidden before the last scene, shown once its clip has played to the end (at
|
|
398
449
|
// once under reduced motion), and a press goes to the body with the focus. The skip link likewise.
|
|
399
450
|
async function checkButtons(page, info) {
|
|
400
|
-
await goToScene(page, info.
|
|
451
|
+
await goToScene(page, info.lastStop - 1);
|
|
401
452
|
const hiddenBefore = !(await ready(page));
|
|
402
|
-
await goToScene(page, info.
|
|
453
|
+
await goToScene(page, info.lastStop);
|
|
403
454
|
const shownAtLast = await ready(page);
|
|
404
455
|
await page.click('.isn-main');
|
|
405
456
|
await settle(page);
|
|
@@ -420,8 +471,8 @@ async function checkInput(page, vp) {
|
|
|
420
471
|
await settle(page);
|
|
421
472
|
out.inertiaStopsAtLast = (await navInfo(page)).state.index === info.last;
|
|
422
473
|
}
|
|
423
|
-
if (info.stops.length - 1 > info.
|
|
424
|
-
for (const how of vp.touch ? ['swipe'] : ['wheel', 'ArrowDown', 'PageDown', 'Space']) out[how] = await bodyRoundTrip(page, vp, how, info.
|
|
474
|
+
if (info.stops.length - 1 > info.lastStop) {
|
|
475
|
+
for (const how of vp.touch ? ['swipe'] : ['wheel', 'ArrowDown', 'PageDown', 'Space']) out[how] = await bodyRoundTrip(page, vp, how, info.lastStop);
|
|
425
476
|
Object.assign(out, await checkButtons(page, info));
|
|
426
477
|
}
|
|
427
478
|
return out;
|
|
@@ -438,7 +489,9 @@ async function measureViewport(browser, url, vp, o) {
|
|
|
438
489
|
const consoleErrors = [];
|
|
439
490
|
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
|
440
491
|
page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
|
|
441
|
-
await page.goto(url, { waitUntil: '
|
|
492
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
493
|
+
// a page whose load does not come within loadMs is measured as it stands, and that is a failure of this screen
|
|
494
|
+
const loadFailures = await loadedWithin(page, o.loadMs) ? [] : [`page load did not finish within ${o.loadMs} ms (measured while still loading)`];
|
|
442
495
|
await page.waitForSelector('.sw-copy');
|
|
443
496
|
const scenes = [];
|
|
444
497
|
for (let i = 0, n = await page.locator('.sw-copy').count(); i < n; i++) scenes.push(await measureScene(page, i, { ...o, reduce: Boolean(vp.reduce) }));
|
|
@@ -446,24 +499,310 @@ async function measureViewport(browser, url, vp, o) {
|
|
|
446
499
|
const buttons = o.contrast ? await topCtaContrast(page) : [];
|
|
447
500
|
await context.close();
|
|
448
501
|
const buttonMisses = buttonFailures(buttons, o.minContrast);
|
|
449
|
-
const failures = scenes.reduce((a, s) => a + s.failures.length, 0) + consoleErrors.length + inputFailures(input).length + buttonMisses.length;
|
|
502
|
+
const failures = scenes.reduce((a, s) => a + s.failures.length, 0) + consoleErrors.length + inputFailures(input).length + buttonMisses.length + loadFailures.length;
|
|
450
503
|
return { viewport: `${vp.width}x${vp.height}${vp.touch ? ' touch' : ''}${vp.reduce ? ' reduced-motion' : ''}`, scenes, input, inputFailures: inputFailures(input),
|
|
451
|
-
buttons, buttonFailures: buttonMisses, consoleErrors, failures };
|
|
504
|
+
buttons, buttonFailures: buttonMisses, consoleErrors, loadFailures, failures };
|
|
452
505
|
}
|
|
453
506
|
|
|
454
|
-
/**
|
|
455
|
-
|
|
456
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Measures `url` at every viewport. Resolves to { url, viewports: [...], failures }; with `seams`,
|
|
509
|
+
* also { seams } from seamProbe at the first viewport, its failures counted in.
|
|
510
|
+
*/
|
|
511
|
+
// captureMs, loadMs, fetchMs: the longest wait for one screen capture, the page's load and intro.js; past them that
|
|
512
|
+
// part is not measured (with the reason, never a pass) and the run goes on. A font or an image that never answers
|
|
513
|
+
// would otherwise hold a capture 30 s and throw away every result.
|
|
514
|
+
const VERIFY_DEFAULTS = { viewports: VIEWPORTS, header: '.sw-topbar', minContrast: 4.5, input: true, contrast: true, playwrightFromProject: false, seams: false, slowClipsMs: 0, seamReload: false,
|
|
515
|
+
captureMs: 10_000, loadMs: 15_000, fetchMs: 15_000 };
|
|
516
|
+
const isTimeout = (e) => e?.name === 'TimeoutError';
|
|
517
|
+
|
|
518
|
+
export async function verifyIntro(url, opts = {}) {
|
|
519
|
+
const o = { ...VERIFY_DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined)) };
|
|
520
|
+
const { chromium } = await loadPlaywright({ fromProject: o.playwrightFromProject });
|
|
457
521
|
const browser = await chromium.launch();
|
|
458
522
|
try {
|
|
459
523
|
const results = [];
|
|
460
|
-
|
|
461
|
-
|
|
524
|
+
const limits = { captureMs: o.captureMs, loadMs: o.loadMs };
|
|
525
|
+
for (const vp of o.viewports) results.push(await measureViewport(browser, url, vp, { header: o.header, minContrast: o.minContrast, input: o.input, contrast: o.contrast, ...limits }));
|
|
526
|
+
const seam = o.seams ? await seamProbe(browser, url, { viewport: o.viewports[0], slowClipsMs: Number(o.slowClipsMs) || 0, reload: Boolean(o.seamReload), ...limits, fetchMs: o.fetchMs }) : null;
|
|
527
|
+
const failures = results.reduce((a, r) => a + r.failures, 0) + (seam ? seam.failures.length : 0);
|
|
528
|
+
return { url, viewports: results, ...(seam ? { seams: seam } : {}), failures };
|
|
462
529
|
} finally {
|
|
463
530
|
await browser.close();
|
|
464
531
|
}
|
|
465
532
|
}
|
|
466
533
|
|
|
534
|
+
// ---- seam probe (--seams) ----------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* steps: captures per seam. windowVh: how far past the seam the probe looks (before it, at least the
|
|
538
|
+
* blank limit plus 0.4 vh). blankVh + the plate share: the longest run of a near one-colour screen a
|
|
539
|
+
* seam may show (a landing on a background plate is one colour for its last frames, so each seam's
|
|
540
|
+
* limit adds plateSec of the clip before it, in vh). flickerDiff: the mean pixel difference (0-255)
|
|
541
|
+
* between the poster a scene shows before its clip paints and the clip once it does, past which the
|
|
542
|
+
* poster is a flicker. settleMs: the longest wait for clips to catch up with the scroll. posterDecodeMs: the
|
|
543
|
+
* longest wait for the posters in view to decode on arrival; past it the seam is unmeasured (posterDiff null, posterWhy).
|
|
544
|
+
* loadMs: the longest wait for the page's load event (a resource that never answers must not stop the probe).
|
|
545
|
+
*/
|
|
546
|
+
export const SEAM_DEFAULTS = { steps: 20, windowVh: 0.6, blankVh: 0.3, plateSec: 0.6, flickerDiff: 24, settleMs: 8000, posterDecodeMs: 5000, loadMs: 15000, captureMs: 10_000, fetchMs: 15_000 };
|
|
547
|
+
const OVERLAYS = ['.sw-copylayer', '.sw-topbar', '.sw-hint', '.sw-route', '.sw-scrollbar', '.isn-hint', '.isn-skip', '.isn-main'];
|
|
548
|
+
|
|
549
|
+
/** The mountScrollWorld config in an assembled intro.js; null when it is not one. */
|
|
550
|
+
export function configFromIntroJs(text) {
|
|
551
|
+
const m = /var config = ([\s\S]*?);\n\s*(?:var nav|mountScrollWorld|if \()/.exec(String(text));
|
|
552
|
+
try { return m ? JSON.parse(m[1]) : null; } catch { return null; }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The engine's segments in scroll order (as scrub-engine.js lays them out), start and end in vh. */
|
|
556
|
+
export function segmentsOf(config) {
|
|
557
|
+
const out = [];
|
|
558
|
+
let off = 0;
|
|
559
|
+
const add = (label, w) => { out.push({ label, start: off, end: off + w }); off += w; };
|
|
560
|
+
(config.sections || []).forEach((s, i, all) => {
|
|
561
|
+
add(`dive ${i + 1}`, s.scroll || config.diveScroll || 1.3);
|
|
562
|
+
if (i < all.length - 1 && (config.connectors || [])[i]) add(`conn ${i + 1}`, config.connScroll || 0.9);
|
|
563
|
+
});
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Mean absolute difference of two RGBA pixel arrays over R, G and B (0-255). */
|
|
568
|
+
export function meanAbsDiff(a, b) {
|
|
569
|
+
let sum = 0;
|
|
570
|
+
for (let i = 0; i < a.length; i += 4) sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
|
|
571
|
+
return Math.round((sum / ((a.length / 4) * 3)) * 100) / 100;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A near one-colour screen, whatever the colour: `share` of the pixels within `tolerance` of the most common colour. */
|
|
575
|
+
export function isBlank(data, { tolerance = 10, share = 0.99 } = {}) {
|
|
576
|
+
const ref = [0, 1, 2].map((c) => modeOf(data, c));
|
|
577
|
+
let near = 0;
|
|
578
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
579
|
+
if (Math.abs(data[i] - ref[0]) <= tolerance && Math.abs(data[i + 1] - ref[1]) <= tolerance && Math.abs(data[i + 2] - ref[2]) <= tolerance) near++;
|
|
580
|
+
}
|
|
581
|
+
return near / (data.length / 4) >= share;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function modeOf(data, c) {
|
|
585
|
+
const hist = new Uint32Array(256);
|
|
586
|
+
for (let i = c; i < data.length; i += 4) hist[data[i]]++;
|
|
587
|
+
return hist.indexOf(Math.max(...hist));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Runs of blank captures longer than limitVh: [{ fromVh, toVh, lengthVh }] (ys are the captures' scroll positions in vh). */
|
|
591
|
+
export function blankRuns(flags, ys, limitVh) {
|
|
592
|
+
const step = ys.length > 1 ? ys[1] - ys[0] : 0;
|
|
593
|
+
const runs = [];
|
|
594
|
+
let from = -1;
|
|
595
|
+
flags.concat(false).forEach((f, i) => {
|
|
596
|
+
if (f && from < 0) from = i;
|
|
597
|
+
if (!f && from >= 0) { runs.push({ fromVh: ys[from], toVh: ys[i - 1], lengthVh: ys[i - 1] - ys[from] + step }); from = -1; }
|
|
598
|
+
});
|
|
599
|
+
const r2 = (x) => Math.round(x * 100) / 100;
|
|
600
|
+
return runs.filter((r) => r.lengthVh > limitVh).map((r) => ({ fromVh: r2(r.fromVh), toVh: r2(r.toVh), lengthVh: r2(r.lengthVh) }));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Waits until the clips of the scenes in view are loaded and not seeking, and their frames hold for six
|
|
604
|
+
// animation frames: the engine eases each clip towards the scroll target over several frames. A scene
|
|
605
|
+
// with no video yet (its clip still on the way) or one that cannot show a frame yet is pending; a loaded
|
|
606
|
+
// clip the engine has not needed to seek (a scene at its very first frame) is not.
|
|
607
|
+
const settleClips = (page, limit) => page.evaluate(async (ms) => {
|
|
608
|
+
const t0 = performance.now();
|
|
609
|
+
let last = '';
|
|
610
|
+
let same = 0;
|
|
611
|
+
const waiting = (e) => { const v = e.querySelector('video'); return v ? v.seeking || v.readyState < 2 : !e.classList.contains('has-clip'); };
|
|
612
|
+
while (performance.now() - t0 < ms) {
|
|
613
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
614
|
+
const seen = [...document.querySelectorAll('.sw-scene')].filter((e) => Number(getComputedStyle(e).opacity) > 0.5);
|
|
615
|
+
const pending = seen.some(waiting);
|
|
616
|
+
const sig = seen.map((e) => e.querySelector('video')?.currentTime.toFixed(3) ?? '-').join(',');
|
|
617
|
+
same = !pending && sig === last ? same + 1 : 0;
|
|
618
|
+
last = sig;
|
|
619
|
+
if (same >= 6) return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}, limit);
|
|
623
|
+
|
|
624
|
+
// Whether a scene in view still shows its poster (no clip painted yet).
|
|
625
|
+
const posterShown = (page) => page.evaluate(() => [...document.querySelectorAll('.sw-scene')]
|
|
626
|
+
.some((e) => Number(getComputedStyle(e).opacity) > 0.5 && !e.classList.contains('has-clip')));
|
|
627
|
+
|
|
628
|
+
const scrollToVh = (page, vh) => page.evaluate((y) => { window.scrollTo(0, y * window.innerHeight); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); }, vh);
|
|
629
|
+
// A seam capture, decoded here: many captures per seam, so not through a browser tab as pixels() does.
|
|
630
|
+
const capture = async (page, ms = SEAM_DEFAULTS.captureMs) => decodePng(await screenshot(page, { type: 'png', timeout: ms })).data;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* RGBA pixels of an 8-bit, non-interlaced PNG (grey, grey+alpha, RGB or RGBA: what a browser
|
|
634
|
+
* screenshot is), decoded with zlib. Throws on any other PNG.
|
|
635
|
+
*/
|
|
636
|
+
export function decodePng(buf) {
|
|
637
|
+
if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('decodePng: not a PNG');
|
|
638
|
+
const { width, height, type, depth, interlace, idat } = pngChunks(buf);
|
|
639
|
+
const ch = { 0: 1, 2: 3, 4: 2, 6: 4 }[type];
|
|
640
|
+
if (depth !== 8 || interlace !== 0 || !ch) throw new Error(`decodePng: only 8-bit non-interlaced PNG (got depth ${depth}, type ${type}, interlace ${interlace})`);
|
|
641
|
+
const raw = unfilter(zlib.inflateSync(idat), width, height, ch);
|
|
642
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
643
|
+
for (let i = 0, j = 0; i < width * height; i++, j += ch) {
|
|
644
|
+
const grey = ch < 3;
|
|
645
|
+
data.set([raw[j], raw[grey ? j : j + 1], raw[grey ? j : j + 2], ch === 2 || ch === 4 ? raw[j + ch - 1] : 255], i * 4);
|
|
646
|
+
}
|
|
647
|
+
return { width, height, data };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function pngChunks(buf) {
|
|
651
|
+
const out = { idat: [] };
|
|
652
|
+
for (let at = 8; at < buf.length;) {
|
|
653
|
+
const len = buf.readUInt32BE(at);
|
|
654
|
+
const kind = buf.toString('latin1', at + 4, at + 8);
|
|
655
|
+
const body = buf.subarray(at + 8, at + 8 + len);
|
|
656
|
+
if (kind === 'IHDR') Object.assign(out, { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], type: body[9], interlace: body[12] });
|
|
657
|
+
if (kind === 'IDAT') out.idat.push(body);
|
|
658
|
+
at += 12 + len;
|
|
659
|
+
}
|
|
660
|
+
return { ...out, idat: Buffer.concat(out.idat) };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Undo the PNG row filters (None, Sub, Up, Average, Paeth).
|
|
664
|
+
function unfilter(src, width, height, bpp) {
|
|
665
|
+
const stride = width * bpp;
|
|
666
|
+
const out = Buffer.alloc(stride * height);
|
|
667
|
+
for (let y = 0; y < height; y++) {
|
|
668
|
+
const f = src[y * (stride + 1)];
|
|
669
|
+
const row = src.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
670
|
+
for (let x = 0; x < stride; x++) {
|
|
671
|
+
const a = x >= bpp ? out[y * stride + x - bpp] : 0;
|
|
672
|
+
const b = y > 0 ? out[(y - 1) * stride + x] : 0;
|
|
673
|
+
const c = x >= bpp && y > 0 ? out[(y - 1) * stride + x - bpp] : 0;
|
|
674
|
+
out[y * stride + x] = (row[x] + predict(f, a, b, c)) & 0xff;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function predict(f, a, b, c) {
|
|
681
|
+
if (f === 1) return a;
|
|
682
|
+
if (f === 2) return b;
|
|
683
|
+
if (f === 3) return (a + b) >> 1;
|
|
684
|
+
if (f !== 4) return 0;
|
|
685
|
+
const p = a + b - c;
|
|
686
|
+
const pa = Math.abs(p - a);
|
|
687
|
+
const pb = Math.abs(p - b);
|
|
688
|
+
const pc = Math.abs(p - c);
|
|
689
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// The clip length of the segment before a seam, from its video (8 s when it is not loaded).
|
|
693
|
+
const clipSeconds = (page, k) => page.evaluate((i) => document.querySelectorAll('.sw-scene')[i]?.querySelector('video')?.duration || 8, k);
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Scrolls across each seam between clips and measures it: the largest difference between successive
|
|
697
|
+
* captures (maxDiff), runs of a near one-colour screen longer than the seam's limit (blank), and whether
|
|
698
|
+
* a poster that showed on arrival differs from the clip once it paints (posterFlicker). Overlays (copy,
|
|
699
|
+
* bars, dots) are hidden while measuring. Each capture waits for the clips to catch up with the scroll.
|
|
700
|
+
*/
|
|
701
|
+
export async function seamProbe(browser, url, { viewport = VIEWPORTS[0], slowClipsMs = 0, ...o } = {}) {
|
|
702
|
+
const opts = { ...SEAM_DEFAULTS, ...o };
|
|
703
|
+
const config = await fetch(new URL('intro.js', url), { signal: AbortSignal.timeout(opts.fetchMs) }).then((res) => res.text()).then(configFromIntroJs).catch(() => null);
|
|
704
|
+
if (!config) return { viewport: `${viewport.width}x${viewport.height}`, boundaries: [], failures: ['seams: intro.js was not read (no answer within fetchMs, or no config in it)'] };
|
|
705
|
+
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1 });
|
|
706
|
+
const page = await context.newPage();
|
|
707
|
+
if (slowClipsMs) await page.route(/\.mp4(\?|$)/, async (route) => { await new Promise((r) => setTimeout(r, slowClipsMs)); await route.continue(); });
|
|
708
|
+
const loadMisses = [];
|
|
709
|
+
const note = (loaded, at) => { if (!loaded) loadMisses.push(`seams: page load did not finish within ${opts.loadMs} ms ${at} (measured while still loading)`); };
|
|
710
|
+
note(await openIntro(page, url, opts.loadMs), 'at the start');
|
|
711
|
+
const segs = segmentsOf(config);
|
|
712
|
+
const boundaries = [];
|
|
713
|
+
for (let k = 1; k < segs.length; k++) {
|
|
714
|
+
if (opts.reload && k > 1) note(await openIntro(page, url, opts.loadMs), `before ${segs[k - 1].label} -> ${segs[k].label}`);
|
|
715
|
+
boundaries.push(await probeSeam(page, segs, k, opts).catch((e) => {
|
|
716
|
+
if (!isTimeout(e)) throw e;
|
|
717
|
+
const why = `screen capture timed out after ${opts.captureMs} ms`; // not measured: never a pass
|
|
718
|
+
return { from: segs[k - 1].label, to: segs[k].label, atVh: segs[k].start, maxDiff: null, blank: [], posterDiff: null, posterWhy: why,
|
|
719
|
+
failures: [`seam ${segs[k - 1].label} -> ${segs[k].label}: not measured (${why})`] };
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
await context.close();
|
|
723
|
+
// a seam whose clip was there on arrival showed no poster: not measured, so not counted as a pass
|
|
724
|
+
const posterUnmeasured = boundaries.filter((b) => b.posterDiff === null).length;
|
|
725
|
+
return { viewport: `${viewport.width}x${viewport.height}`, slowClipsMs, reload: Boolean(opts.reload), posterMeasured: boundaries.length - posterUnmeasured, posterUnmeasured, boundaries, failures: [...loadMisses, ...boundaries.flatMap((b) => b.failures)] };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Whether the page's load event came within ms (a resource whose answer never comes must not stop a measure).
|
|
729
|
+
const loadedWithin = (page, ms) => page.waitForLoadState('load', { timeout: ms }).then(() => true, () => false);
|
|
730
|
+
|
|
731
|
+
// Opens the intro for the seam probe; false when its load did not come within loadMs (the caller fails it).
|
|
732
|
+
// Routing (the slow clips) turns the browser's HTTP cache off, so a fresh load fetches every clip again.
|
|
733
|
+
async function openIntro(page, url, loadMs = SEAM_DEFAULTS.loadMs) {
|
|
734
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
735
|
+
const loaded = await loadedWithin(page, loadMs);
|
|
736
|
+
await page.waitForSelector('.sw-scene');
|
|
737
|
+
// the probe parks the page between stops: step navigation must not settle it on one
|
|
738
|
+
await page.evaluate(() => window.IntroStepNav?.current?.hold(true));
|
|
739
|
+
await page.evaluate((sels) => { for (const el of document.querySelectorAll(sels.join(','))) el.style.visibility = 'hidden'; }, OVERLAYS);
|
|
740
|
+
return loaded;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// The scenes in view: whether each shows its clip, its poster's load state and transform, its video's frame.
|
|
744
|
+
const sceneStates = (page) => page.evaluate(() => [...document.querySelectorAll('.sw-scene')].map((e, scene) => ({ e, scene }))
|
|
745
|
+
.filter(({ e }) => Number(getComputedStyle(e).opacity) > 0.5).map(({ e, scene }) => {
|
|
746
|
+
const img = e.querySelector('.sw-scene__still');
|
|
747
|
+
const v = e.querySelector('video');
|
|
748
|
+
return { scene, hasClip: e.classList.contains('has-clip'),
|
|
749
|
+
poster: img ? { complete: img.complete, naturalWidth: img.naturalWidth, transform: getComputedStyle(img).transform } : null,
|
|
750
|
+
video: v ? { readyState: v.readyState, currentTime: Number(v.currentTime.toFixed(3)) } : null };
|
|
751
|
+
}));
|
|
752
|
+
|
|
753
|
+
async function probeSeam(page, segs, k, o) {
|
|
754
|
+
const at = segs[k].start;
|
|
755
|
+
const prev = segs[k - 1];
|
|
756
|
+
const before = Math.max(o.windowVh, o.blankVh + 0.4 + (o.plateSec / 2) * (prev.end - prev.start));
|
|
757
|
+
const arrival = await arrive(page, at + 0.02, o);
|
|
758
|
+
const limit = o.blankVh + (o.plateSec / (await clipSeconds(page, k - 1))) * (prev.end - prev.start);
|
|
759
|
+
const ys = Array.from({ length: o.steps + 1 }, (_, i) => at - before + ((before + o.windowVh) * i) / o.steps);
|
|
760
|
+
const flags = [];
|
|
761
|
+
let maxDiff = 0;
|
|
762
|
+
let last = null;
|
|
763
|
+
for (const y of ys) {
|
|
764
|
+
await scrollToVh(page, y);
|
|
765
|
+
await settleClips(page, o.settleMs);
|
|
766
|
+
const px = await capture(page, o.captureMs);
|
|
767
|
+
if (last) maxDiff = Math.max(maxDiff, meanAbsDiff(last, px));
|
|
768
|
+
flags.push(isBlank(px));
|
|
769
|
+
last = px;
|
|
770
|
+
}
|
|
771
|
+
const blank = blankRuns(flags, ys, limit);
|
|
772
|
+
const failures = [
|
|
773
|
+
...blank.map((b) => `seam ${prev.label} -> ${segs[k].label}: a near one-colour screen for ${b.lengthVh} vh (limit ${Math.round(limit * 100) / 100} vh)`),
|
|
774
|
+
...(arrival.flicker ? [`seam ${prev.label} -> ${segs[k].label}: the poster differs from the clip by ${arrival.diff} (limit ${o.flickerDiff})`] : []),
|
|
775
|
+
];
|
|
776
|
+
return { from: prev.label, to: segs[k].label, atVh: at, maxDiff, blank, blankLimitVh: Math.round(limit * 100) / 100, posterDiff: arrival.diff,
|
|
777
|
+
...(arrival.why ? { posterWhy: arrival.why } : {}), arrival: arrival.states, failures };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Jump just past the seam, where the entering clip is at its first frames; when a poster shows there,
|
|
781
|
+
// compare it with the clip once it paints. A poster that is the clip's own first frame barely differs.
|
|
782
|
+
async function arrive(page, vh, o) {
|
|
783
|
+
await scrollToVh(page, vh);
|
|
784
|
+
// a scene shown by the jump paints nothing until its poster is decoded (0.1-0.35 s seen): wait for that, so the
|
|
785
|
+
// capture is the poster, not the empty frame a jump shows (the empty frame is another matter than the flicker)
|
|
786
|
+
// at most posterDecodeMs: a poster whose answer never comes leaves this seam unmeasured, with the reason
|
|
787
|
+
const decoded = await page.evaluate((ms) => Promise.race([
|
|
788
|
+
Promise.all([...document.querySelectorAll('.sw-scene')].filter((e) => Number(getComputedStyle(e).opacity) > 0.5)
|
|
789
|
+
.map((e) => e.querySelector('.sw-scene__still')?.decode().catch(() => {}))).then(() => true),
|
|
790
|
+
new Promise((r) => setTimeout(() => r(false), ms)),
|
|
791
|
+
]).then((ok) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(ok))))), o.posterDecodeMs);
|
|
792
|
+
const before = await sceneStates(page);
|
|
793
|
+
if (!decoded) return { diff: null, flicker: false, why: `a poster did not decode within ${o.posterDecodeMs} ms`, states: { before } };
|
|
794
|
+
if (!(await posterShown(page))) { await settleClips(page, o.settleMs); return { diff: null, flicker: false, states: { before } }; }
|
|
795
|
+
const early = await capture(page, o.captureMs);
|
|
796
|
+
await page.waitForFunction(() => [...document.querySelectorAll('.sw-scene')]
|
|
797
|
+
.every((e) => Number(getComputedStyle(e).opacity) <= 0.5 || e.classList.contains('has-clip')), null, { timeout: o.settleMs }).catch(() => {});
|
|
798
|
+
await settleClips(page, o.settleMs);
|
|
799
|
+
const diff = meanAbsDiff(early, await capture(page, o.captureMs));
|
|
800
|
+
return { diff, flicker: diff > o.flickerDiff, states: { before, after: await sceneStates(page) } };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The CLI's exit code: 1 on a failure, 3 when none failed but a seam's poster went unmeasured, else 0. */
|
|
804
|
+
export const exitCode = (report) => (report.failures ? 1 : report.seams?.posterUnmeasured ? 3 : 0);
|
|
805
|
+
|
|
467
806
|
// '390x844t' is a touch screen, '1280x800r' reduced motion; the flags may combine ('390x844tr').
|
|
468
807
|
export function parseViewport(v) {
|
|
469
808
|
const m = /^(\d+)x(\d+)([tr]*)$/.exec(v.trim());
|
|
@@ -471,12 +810,12 @@ export function parseViewport(v) {
|
|
|
471
810
|
return { width: Number(m[1]), height: Number(m[2]), touch: m[3].includes('t'), reduce: m[3].includes('r') };
|
|
472
811
|
}
|
|
473
812
|
|
|
474
|
-
const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports' };
|
|
475
|
-
const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false], '--playwright-from-project': ['playwrightFromProject', true] };
|
|
813
|
+
const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports', '--slow-clips': 'slowClipsMs' };
|
|
814
|
+
const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false], '--playwright-from-project': ['playwrightFromProject', true], '--seams': ['seams', true], '--seam-reload': ['seamReload', true] };
|
|
476
815
|
|
|
477
816
|
/** The command line: a URL, --flag value or --flag=value, and the switches. */
|
|
478
817
|
export function parseArgs(argv) {
|
|
479
|
-
const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true, playwrightFromProject: false };
|
|
818
|
+
const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true, playwrightFromProject: false, seams: false, slowClipsMs: 0, seamReload: false };
|
|
480
819
|
for (let i = 0; i < argv.length; i++) {
|
|
481
820
|
const [flag, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
|
|
482
821
|
if (SWITCHES[flag]) o[SWITCHES[flag][0]] = SWITCHES[flag][1];
|
|
@@ -485,6 +824,7 @@ export function parseArgs(argv) {
|
|
|
485
824
|
else o.url = argv[i];
|
|
486
825
|
}
|
|
487
826
|
o.minContrast = Number(o.minContrast);
|
|
827
|
+
o.slowClipsMs = Number(o.slowClipsMs) || 0;
|
|
488
828
|
if (typeof o.viewports === 'string') o.viewports = o.viewports.split(',').map(parseViewport);
|
|
489
829
|
return o;
|
|
490
830
|
}
|
|
@@ -502,14 +842,15 @@ if (invokedDirectly()) {
|
|
|
502
842
|
let o;
|
|
503
843
|
try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
|
|
504
844
|
if (!o.url) {
|
|
505
|
-
console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]');
|
|
845
|
+
console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project] [--seams] [--slow-clips <ms>] [--seam-reload]');
|
|
506
846
|
process.exit(2);
|
|
507
847
|
}
|
|
508
848
|
verifyIntro(o.url, o).then((r) => {
|
|
509
849
|
const text = JSON.stringify(r, null, 2);
|
|
510
850
|
if (o.out) fs.writeFileSync(o.out, text + '\n');
|
|
511
851
|
else console.log(text);
|
|
512
|
-
|
|
513
|
-
|
|
852
|
+
const unmeasured = r.seams?.posterUnmeasured || 0;
|
|
853
|
+
console.error(`verify-intro: ${r.failures} failure(s) across ${r.viewports.length} screen size(s)${unmeasured ? `; poster not measured at ${unmeasured} seam(s) (add --slow-clips and --seam-reload)` : ''}`);
|
|
854
|
+
process.exit(exitCode(r));
|
|
514
855
|
}).catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
515
856
|
}
|
|
@@ -9,6 +9,7 @@ Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright
|
|
|
9
9
|
```bash
|
|
10
10
|
node references/verify-intro.mjs http://localhost:<port>/ --out verify.json
|
|
11
11
|
node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r --out verify-reduced.json # 움직임 줄이기
|
|
12
|
+
node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800 --no-input --seams --slow-clips 4000 --out verify-seams.json # 이음매 탐침(느린 적재 흉내)
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
| 항목 | 기준 |
|
|
@@ -25,6 +26,7 @@ node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r
|
|
|
25
26
|
| 입력 (본문이 있을 때) | 마지막 장면에서 휠, 아래 방향키, PageDown, Space, 스와이프 1회로 본문 맨 위에 섬. 위 입력 1회로 마지막 장면 복귀 |
|
|
26
27
|
| 버튼과 링크 | 메인화면 버튼은 마지막 장면 전에는 숨고 도착하면 보임. 누르면 본문으로 가서 포커스가 본문에 감. 건너뛰기 링크도 같음 |
|
|
27
28
|
| 움직임 줄이기 | 이동 시간 0. 메인화면 버튼이 마지막 장면에서 보임 |
|
|
29
|
+
| 이음매 (`--seams`) | 첫 화면 크기에서 클립 사이 경계마다 앞뒤를 20 단계로 스크롤하며, 클립이 스크롤을 따라잡고 `seeked` 를 마친 뒤 찍는다(문구, 막대, 점은 숨김). 빈 화면(색과 관계없이 거의 한 색인 화면)이 이어지는 길이가 0.3 vh 에 앞 클립의 배경 판 몫(0.6초가 스크롤에서 차지하는 길이)을 더한 한도를 넘으면 실패. 도착할 때 포스터가 보이면 클립이 그려진 뒤와 비교해 평균 픽셀 차이가 24 를 넘으면 포스터 깜빡임 실패. 연속 화면 차이의 최대값(`maxDiff`)은 기록만 한다. `--slow-clips <ms>` 는 클립 요청마다 그만큼 늦춰 느린 망을 흉내 낸다 |
|
|
28
30
|
|
|
29
31
|
## 2. 눈으로 보는 항목
|
|
30
32
|
|
|
@@ -36,6 +38,10 @@ node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r
|
|
|
36
38
|
| 화면 속 글자 | 깨진 글자, 뜻 없는 글자가 없음. 있으면 그 스틸이나 클립만 재생성 |
|
|
37
39
|
| 사물 | 사물이 엉뚱한 위치에 뜨거나 이상하게 움직이지 않음. 색이 다른 물체로 번지지 않음 |
|
|
38
40
|
| 이음매 | chain 은 경미한 톤 drift 까지 허용. holdFlight 는 비행 클립의 첫과 끝 프레임이 장면과 맞음 |
|
|
41
|
+
| 배경 이음매 (dive) | 배경 판 착지가 켜진 이음매는 이음매 쪽 클립(나가기, 없으면 dive)의 끝이 판 한 색으로 끝나고, 다음 장면이 같은 바탕 위에서 시작함. 판 색이 다음 장면 바탕과 눈에 띄게 다르면 지적. 착지가 꺼진 이음매(테두리가 한 색이 아닌 스틸)는 엔진 디졸브만 보임 |
|
|
42
|
+
| 회전 (dive arc) | `arc` 장면은 카메라가 초점 대상을 가운데 두고 실제로 돌며 내려가는 것이 보임(대략 4분의 1 바퀴). 줌만 보이고 돌지 않으면 지적. `cameraMotion` 의 roll 근사(누적 회전각)는 참고값이다. 평행 이동은 재지 않고, 대상 둘레를 도는 궤도는 화면의 roll 과 다르므로 판정은 눈으로 한다 |
|
|
43
|
+
| 결론 착지 (dive finale) | 마지막 장면 클립이 결론 스틸 그대로의 화면으로 끝남. 뒤집힌 클립이라 사람, 연기, 물의 움직임이 거꾸로 보이면 지적 |
|
|
44
|
+
| 포스터 (dive 나가기) | 느린 망에서도 나가기 구간에 들어설 때 다음 장면이 잠깐 보였다가 이전 장면 실내로 돌아가는 튐이 없음(`connectorStills` 가 그 클립의 첫 프레임을 포스터로 둠) |
|
|
39
45
|
| 첫 이동 | 첫 장면에서 처음 움직일 때 영상이 바로 나옴(클립 미리 받기) |
|
|
40
46
|
| 문구 | 375px 화면에서 본문이 넘치지 않음. 유리 패널이면 본문은 한 줄에 항목 하나(권장 세 줄). CTA 는 마지막 장면에만 |
|
|
41
47
|
|