@kaydash9999/banker-plugins 0.21.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 +69 -0
- package/PRIVACY.md +8 -3
- package/README.md +78 -15
- package/bin/banker.js +154 -32
- package/bin/lib/codex-hooks.mjs +123 -20
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +204 -0
- package/bin/lib/command-skill.cjs +50 -0
- package/bin/lib/telemetry-config.mjs +2 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +18 -15
- package/codex/skills/graceful-pause/SKILL.md +2 -2
- 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/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +303 -35
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +146 -13
- 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 +387 -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/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/lineage/lineage.py +1435 -533
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +20 -5
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +2 -2
- package/skills/play-qa/SKILL.md +7 -5
- 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 +105 -16
- 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 +2 -1
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +2 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- 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/smart-compact/SKILL.md +1 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +34 -16
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- 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 +32 -7
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- package/skills/vertical-pptx/build_a4p.py +560 -359
|
@@ -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.
|
|
@@ -304,11 +334,26 @@ async function pixels(context, png) {
|
|
|
304
334
|
return { width: img.width, height: img.height, data: Uint8ClampedArray.from(img.data) };
|
|
305
335
|
}
|
|
306
336
|
|
|
307
|
-
|
|
337
|
+
// Chromium now and then answers a screenshot with "Unable to capture screenshot" while it is busy (seen once in three
|
|
338
|
+
// full runs on a loaded Linux box). That error alone is tried again, up to three times with a short wait; any other
|
|
339
|
+
// error is the page's and is thrown at once.
|
|
340
|
+
export async function screenshot(page, opts, { tries = 3, waitMs = 150 } = {}) {
|
|
341
|
+
for (let n = 1; ; n++) {
|
|
342
|
+
try {
|
|
343
|
+
return await page.screenshot(opts);
|
|
344
|
+
} catch (e) {
|
|
345
|
+
if (n >= tries || !/Unable to capture screenshot/i.test(String(e?.message))) throw e;
|
|
346
|
+
await new Promise((r) => setTimeout(r, waitMs * n));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function lineContrast(page, i, panel, minContrast = 4.5, captureMs = 10_000) {
|
|
308
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) };
|
|
309
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);
|
|
310
355
|
await page.waitForTimeout(80);
|
|
311
|
-
const png = await
|
|
356
|
+
const png = await screenshot(page, { type: 'png', clip, timeout: captureMs });
|
|
312
357
|
await page.evaluate(showText, { i, parts: TEXT_PARTS });
|
|
313
358
|
const img = await pixels(page.context(), png);
|
|
314
359
|
return lines.map((l) => {
|
|
@@ -318,15 +363,29 @@ async function lineContrast(page, i, panel) {
|
|
|
318
363
|
const rgb = parseColor(l.color);
|
|
319
364
|
if (!rgb) return { el: l.el, line: l.line, min: null, why: `colour ${l.color}` };
|
|
320
365
|
if (rect.width < 1 || rect.height < 1) return { el: l.el, line: l.line, min: null, why: 'line outside the panel' };
|
|
321
|
-
|
|
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) };
|
|
322
368
|
});
|
|
323
369
|
}
|
|
324
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
|
+
|
|
325
379
|
async function measureScene(page, i, o) {
|
|
326
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);
|
|
327
383
|
const clip = await clipState(page, i, o.reduce);
|
|
328
384
|
const g = geometry(await page.evaluate(rawGeometry, { i, header: o.header, covers: COVERS }));
|
|
329
|
-
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
|
+
}) : [];
|
|
330
389
|
const scene = { scene: i + 1, clip, ...g, contrast };
|
|
331
390
|
return { ...scene, failures: g.opacity > 0.5 ? judge(scene, o) : ['panel not visible at its stop'] };
|
|
332
391
|
}
|
|
@@ -363,15 +422,21 @@ async function notchWithInertia(page, vp) {
|
|
|
363
422
|
}
|
|
364
423
|
}
|
|
365
424
|
|
|
366
|
-
|
|
367
|
-
|
|
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);
|
|
368
433
|
await page.waitForTimeout(400);
|
|
369
|
-
await
|
|
434
|
+
await sendStep(page, vp, how, 'down');
|
|
370
435
|
await settle(page);
|
|
371
436
|
const reachesBody = (await navInfo(page)).state.atBody;
|
|
372
|
-
await
|
|
437
|
+
await sendStep(page, vp, how, 'up');
|
|
373
438
|
await settle(page);
|
|
374
|
-
return { reachesBody, upReturns: (await navInfo(page)).state.index ===
|
|
439
|
+
return { reachesBody, upReturns: (await navInfo(page)).state.index === lastStop };
|
|
375
440
|
}
|
|
376
441
|
|
|
377
442
|
const ready = (page) => page.evaluate(() => {
|
|
@@ -383,9 +448,9 @@ const bodyFocused = (page, end) => page.evaluate((sel) => document.activeElement
|
|
|
383
448
|
// The main button: hidden before the last scene, shown once its clip has played to the end (at
|
|
384
449
|
// once under reduced motion), and a press goes to the body with the focus. The skip link likewise.
|
|
385
450
|
async function checkButtons(page, info) {
|
|
386
|
-
await goToScene(page, info.
|
|
451
|
+
await goToScene(page, info.lastStop - 1);
|
|
387
452
|
const hiddenBefore = !(await ready(page));
|
|
388
|
-
await goToScene(page, info.
|
|
453
|
+
await goToScene(page, info.lastStop);
|
|
389
454
|
const shownAtLast = await ready(page);
|
|
390
455
|
await page.click('.isn-main');
|
|
391
456
|
await settle(page);
|
|
@@ -406,8 +471,8 @@ async function checkInput(page, vp) {
|
|
|
406
471
|
await settle(page);
|
|
407
472
|
out.inertiaStopsAtLast = (await navInfo(page)).state.index === info.last;
|
|
408
473
|
}
|
|
409
|
-
if (info.stops.length - 1 > info.
|
|
410
|
-
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);
|
|
411
476
|
Object.assign(out, await checkButtons(page, info));
|
|
412
477
|
}
|
|
413
478
|
return out;
|
|
@@ -424,7 +489,9 @@ async function measureViewport(browser, url, vp, o) {
|
|
|
424
489
|
const consoleErrors = [];
|
|
425
490
|
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
|
426
491
|
page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
|
|
427
|
-
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)`];
|
|
428
495
|
await page.waitForSelector('.sw-copy');
|
|
429
496
|
const scenes = [];
|
|
430
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) }));
|
|
@@ -432,24 +499,310 @@ async function measureViewport(browser, url, vp, o) {
|
|
|
432
499
|
const buttons = o.contrast ? await topCtaContrast(page) : [];
|
|
433
500
|
await context.close();
|
|
434
501
|
const buttonMisses = buttonFailures(buttons, o.minContrast);
|
|
435
|
-
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;
|
|
436
503
|
return { viewport: `${vp.width}x${vp.height}${vp.touch ? ' touch' : ''}${vp.reduce ? ' reduced-motion' : ''}`, scenes, input, inputFailures: inputFailures(input),
|
|
437
|
-
buttons, buttonFailures: buttonMisses, consoleErrors, failures };
|
|
504
|
+
buttons, buttonFailures: buttonMisses, consoleErrors, loadFailures, failures };
|
|
438
505
|
}
|
|
439
506
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
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 });
|
|
443
521
|
const browser = await chromium.launch();
|
|
444
522
|
try {
|
|
445
523
|
const results = [];
|
|
446
|
-
|
|
447
|
-
|
|
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 };
|
|
448
529
|
} finally {
|
|
449
530
|
await browser.close();
|
|
450
531
|
}
|
|
451
532
|
}
|
|
452
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
|
+
|
|
453
806
|
// '390x844t' is a touch screen, '1280x800r' reduced motion; the flags may combine ('390x844tr').
|
|
454
807
|
export function parseViewport(v) {
|
|
455
808
|
const m = /^(\d+)x(\d+)([tr]*)$/.exec(v.trim());
|
|
@@ -457,12 +810,12 @@ export function parseViewport(v) {
|
|
|
457
810
|
return { width: Number(m[1]), height: Number(m[2]), touch: m[3].includes('t'), reduce: m[3].includes('r') };
|
|
458
811
|
}
|
|
459
812
|
|
|
460
|
-
const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports' };
|
|
461
|
-
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] };
|
|
462
815
|
|
|
463
816
|
/** The command line: a URL, --flag value or --flag=value, and the switches. */
|
|
464
817
|
export function parseArgs(argv) {
|
|
465
|
-
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 };
|
|
466
819
|
for (let i = 0; i < argv.length; i++) {
|
|
467
820
|
const [flag, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
|
|
468
821
|
if (SWITCHES[flag]) o[SWITCHES[flag][0]] = SWITCHES[flag][1];
|
|
@@ -471,6 +824,7 @@ export function parseArgs(argv) {
|
|
|
471
824
|
else o.url = argv[i];
|
|
472
825
|
}
|
|
473
826
|
o.minContrast = Number(o.minContrast);
|
|
827
|
+
o.slowClipsMs = Number(o.slowClipsMs) || 0;
|
|
474
828
|
if (typeof o.viewports === 'string') o.viewports = o.viewports.split(',').map(parseViewport);
|
|
475
829
|
return o;
|
|
476
830
|
}
|
|
@@ -488,14 +842,15 @@ if (invokedDirectly()) {
|
|
|
488
842
|
let o;
|
|
489
843
|
try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
|
|
490
844
|
if (!o.url) {
|
|
491
|
-
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]');
|
|
492
846
|
process.exit(2);
|
|
493
847
|
}
|
|
494
848
|
verifyIntro(o.url, o).then((r) => {
|
|
495
849
|
const text = JSON.stringify(r, null, 2);
|
|
496
850
|
if (o.out) fs.writeFileSync(o.out, text + '\n');
|
|
497
851
|
else console.log(text);
|
|
498
|
-
|
|
499
|
-
|
|
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));
|
|
500
855
|
}).catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
501
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
|
|