clearotron 0.2.0 → 0.3.0-beta.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/.env.example +52 -0
- package/INSTALL.md +9 -7
- package/README.md +2 -1
- package/bin/example.mjs +97 -32
- package/bin/onboard.mjs +58 -20
- package/bin/start.mjs +7 -0
- package/bin/stop.mjs +65 -3
- package/build-info.json +2 -2
- package/docs/RELEASES.md +6 -4
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +51 -0
- package/driver/ask-ledger.mjs +69 -1
- package/driver/declination-call.mjs +32 -0
- package/driver/driver.config.mjs +20 -0
- package/driver/engine/mcp/recording-server.mjs +4 -0
- package/driver/gateway.mjs +8 -3
- package/driver/knockout-assess-record.mjs +5 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +123 -3
- package/driver/predelivery-lint.mjs +23 -5
- package/driver/publish/knockout.mjs +12 -6
- package/driver/publish/render-knockout.mjs +133 -23
- package/driver/publish/report-data.mjs +13 -3
- package/driver/publish/seed-pool.mjs +24 -9
- package/driver/record-carry.mjs +139 -0
- package/driver/reference-score.mjs +53 -3
- package/driver/reference-strip-signatures.mjs +68 -0
- package/driver/register-digest-record.mjs +31 -1
- package/driver/repairs.mjs +1 -1
- package/driver/result-noun-fields.mjs +7 -0
- package/driver/skills/knockout-assess/SKILL.md +10 -4
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +108 -30
- package/driver/unit-inventory.mjs +47 -0
- package/driver/unit-state-verdict.mjs +8 -8
- package/driver/verify-knockout.mjs +9 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +45 -4
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/lib/brief.mjs +15 -0
- package/mcp-server/lib/driver.mjs +6 -0
- package/mcp-server/lib/knockout.mjs +435 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +10 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +69 -4
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +10 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ai-page-render-check.mjs +2 -1
- package/scripts/clearances-render-check.mjs +2 -1
- package/scripts/drain-preflight.mjs +2 -2
- package/scripts/env-audit.mjs +20 -0
- package/scripts/freeze-example-run.mjs +3 -3
- package/scripts/headless-page.mjs +274 -0
- package/scripts/home-render-check.mjs +2 -1
- package/scripts/live-surface-check.mjs +86 -17
- package/scripts/mint-reference-strip-backlog.mjs +41 -0
- package/scripts/release-await-cut.mjs +95 -7
- package/scripts/release-version-pr-checks.mjs +25 -1
- package/scripts/render-check.mjs +61 -2
- package/scripts/report-frame-check.mjs +12 -0
- package/scripts/report-screenshot.mjs +62 -2
- package/scripts/revisit-render-check.mjs +3 -2
- package/scripts/score.mjs +14 -0
- package/shared/access-audience.mjs +215 -0
- package/shared/tracked-files.mjs +31 -0
- package/scripts/deploy-test.sh +0 -309
|
@@ -90,7 +90,9 @@ import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
|
|
|
90
90
|
import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, two readers
|
|
91
91
|
import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
|
|
92
92
|
import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
|
|
93
|
-
import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory } from "../driver/unit-inventory.mjs"; // · -bundle ·
|
|
93
|
+
import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
|
|
94
|
+
import { entrypointOf } from "../driver/systemd/install-census.mjs"; // the ONE ExecStart parser — a unit says which module it runs
|
|
95
|
+
import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and the live argv says which tree that module came from
|
|
94
96
|
import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
|
|
95
97
|
import { managerGroupsVerdict } from "../driver/manager-groups-verdict.mjs"; //
|
|
96
98
|
import { config } from "../driver/driver.config.mjs"; //
|
|
@@ -234,14 +236,20 @@ function serviceClones() {
|
|
|
234
236
|
const out = [];
|
|
235
237
|
let reached = 0, lastErr = null;
|
|
236
238
|
for (const u of units) {
|
|
237
|
-
let wd = null, active = null, type = null, since = null;
|
|
239
|
+
let wd = null, active = null, type = null, since = null, mainPid = null, fragment = null;
|
|
238
240
|
try {
|
|
239
241
|
// — `Type` and `StateChangeTimestamp` ride along on a call that was already being made. Both
|
|
240
242
|
// are for the MESSAGE, never for the verdict: Type tells a reader whether an `activating` unit is a
|
|
241
243
|
// oneshot mid-fire or a service mid-restart, and the timestamp lets a human judge a long
|
|
242
244
|
// `activating` that this check deliberately does not judge (see driver/unit-state-verdict.mjs).
|
|
245
|
+
//
|
|
246
|
+
// `MainPID` and `FragmentPath` ride along for the SECOND way to attribute a unit to a checkout,
|
|
247
|
+
// below. Same call, no extra round trip. Deliberately NOT `ExecStart`: systemd renders it
|
|
248
|
+
// unexpanded — `argv[]=/usr/bin/node ${CLEAROTRON_CHECKOUT_DIR}/driver/runner.mjs` — so the one
|
|
249
|
+
// field that looks like it names the tree is the one field that does not.
|
|
243
250
|
const shown = execFileSync("systemctl", ["--user", "show", u,
|
|
244
|
-
"-p", "WorkingDirectory", "-p", "ActiveState", "-p", "Type", "-p", "StateChangeTimestamp"
|
|
251
|
+
"-p", "WorkingDirectory", "-p", "ActiveState", "-p", "Type", "-p", "StateChangeTimestamp",
|
|
252
|
+
"-p", "MainPID", "-p", "FragmentPath"],
|
|
245
253
|
{ encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
|
|
246
254
|
// `show` answers for a unit that does not exist too (ActiveState=inactive), so a PARSED answer is
|
|
247
255
|
// proof the bus was reachable — which is exactly the fact the old catch destroyed.
|
|
@@ -252,19 +260,73 @@ function serviceClones() {
|
|
|
252
260
|
if (k === "ActiveState") active = v.join("=") || null;
|
|
253
261
|
if (k === "Type") type = v.join("=") || null;
|
|
254
262
|
if (k === "StateChangeTimestamp") since = v.join("=") || null;
|
|
263
|
+
if (k === "MainPID") mainPid = v.join("=") || null;
|
|
264
|
+
if (k === "FragmentPath") fragment = v.join("=") || null;
|
|
255
265
|
}
|
|
256
266
|
} catch (e) { lastErr = String(e?.stderr || e?.message || e).replace(/\s+/g, " ").trim().slice(0, 160); }
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
|
|
268
|
+
// ── ATTRIBUTION ONE: THE DECLARATION ────────────────────────────────────────────────────────────
|
|
269
|
+
let declaredTree = null, declaredWhy = null;
|
|
270
|
+
if (!wd) declaredWhy = "the unit reported no WorkingDirectory";
|
|
271
|
+
else {
|
|
272
|
+
const parsed = unitWorkingDirectory(wd);
|
|
273
|
+
if (!parsed.path) declaredWhy = parsed.why;
|
|
274
|
+
else {
|
|
275
|
+
const top = gitTry(parsed.path, "rev-parse", "--show-toplevel");
|
|
276
|
+
if (top.ok) declaredTree = top.out;
|
|
277
|
+
else declaredWhy = `git could not read ${parsed.path}: ${top.err}`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── ATTRIBUTION TWO: WHAT THE PROCESS IS ACTUALLY RUNNING ───────────────────────────────────────
|
|
282
|
+
//
|
|
283
|
+
// The unit file names its entrypoint as `${CLEAROTRON_CHECKOUT_DIR}/<module>`; the LIVE process was
|
|
284
|
+
// started with that expanded, and `/proc/<pid>/cmdline` carries the resolved absolute path. So the
|
|
285
|
+
// unit file says WHICH module to look for and the process says WHERE it came from. Both readers
|
|
286
|
+
// already exist and are tested — this composes them rather than parsing anything new.
|
|
287
|
+
let runningTree = null, runningWhy = null;
|
|
288
|
+
const pid = Number(mainPid);
|
|
289
|
+
if (!pid) runningWhy = "the unit reported no MainPID, so no running process could be read";
|
|
290
|
+
else if (!fragment) runningWhy = `pid ${pid} is running but the unit reported no FragmentPath, so nothing says which module to look for`;
|
|
291
|
+
else {
|
|
292
|
+
let unitText = null;
|
|
293
|
+
try { unitText = readFileSync(fragment, "utf8"); }
|
|
294
|
+
catch (e) { runningWhy = `the unit file ${fragment} could not be read: ${String(e?.message ?? e).slice(0, 80)}`; }
|
|
295
|
+
if (unitText !== null) {
|
|
296
|
+
const { rel, unreadable } = entrypointOf(unitText);
|
|
297
|
+
if (!rel) runningWhy = unreadable;
|
|
298
|
+
else {
|
|
299
|
+
let cmdline = null;
|
|
300
|
+
try { cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8"); }
|
|
301
|
+
catch (e) { runningWhy = `/proc/${pid}/cmdline could not be read: ${String(e?.message ?? e).slice(0, 80)}`; }
|
|
302
|
+
if (cmdline !== null) {
|
|
303
|
+
const { tree, why } = treeOfRunning(cmdline, rel);
|
|
304
|
+
if (!tree) runningWhy = why;
|
|
305
|
+
else {
|
|
306
|
+
const top = gitTry(tree, "rev-parse", "--show-toplevel");
|
|
307
|
+
if (top.ok) runningTree = top.out;
|
|
308
|
+
else runningWhy = `git could not read ${tree}, which pid ${pid} is running from: ${top.err}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const chosen = unitClone({ declaredTree, runningTree, declaredWhy, runningWhy });
|
|
316
|
+
// — an inactive unit that reported nothing is NOT a gap in the population; there is genuinely
|
|
317
|
+
// nothing to compare, and `unreadable: null` says so. A unit that IS running and could not be
|
|
318
|
+
// attributed is a gap, and the reason names both halves.
|
|
319
|
+
if (!chosen.clone) {
|
|
320
|
+
const idle = !wd && !pid;
|
|
321
|
+
out.push({ unit: u, active, type, since, clone: null, head: null, source: null,
|
|
322
|
+
unreadable: idle ? null : chosen.why });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const head = gitTry(chosen.clone, "rev-parse", "HEAD");
|
|
326
|
+
out.push({ unit: u, active, type, since, clone: chosen.clone, source: chosen.source,
|
|
327
|
+
disagreement: chosen.disagreement,
|
|
328
|
+
head: head.ok ? head.out : null,
|
|
329
|
+
unreadable: head.ok ? null : `git could not read HEAD in ${chosen.clone}: ${head.err}` });
|
|
268
330
|
}
|
|
269
331
|
const probe = reached > 0
|
|
270
332
|
? { ok: true, why: null }
|
|
@@ -441,7 +503,12 @@ try {
|
|
|
441
503
|
//
|
|
442
504
|
// The arm above already travels the route the harness uses (`mcpToolCall` on `MCP_URL`, the same
|
|
443
505
|
// client `scripts/e2e.mjs` enqueues through), so a door that 401s every caller ALREADY fails this
|
|
444
|
-
// check and already fails the deploy —
|
|
506
|
+
// check and already fails the deploy — WHEN a deploy runs this script, which at the time of writing
|
|
507
|
+
// nothing does. `scripts/deploy-test.sh` gated on this script's exit code; that script is retired, and
|
|
508
|
+
// what replaced it was measured on the operations side and does NOT invoke this one. Nothing here is
|
|
509
|
+
// wrong: the check is a real instrument and its reasoning holds. It is simply not reached, and a caller
|
|
510
|
+
// is being wired back in. Until it is, read this as what the check is FOR, not as evidence that
|
|
511
|
+
// something enforces it.
|
|
445
512
|
// That is the issue's first criterion, by its sanctioned second branch, and its third and fourth.
|
|
446
513
|
//
|
|
447
514
|
// What neither arm could say is WHY the door has the posture it has. This face reaches the auth-proxy
|
|
@@ -592,8 +659,10 @@ const { clones, probe: unitProbe } = serviceClones();
|
|
|
592
659
|
const running = clones.filter((c) => c.head);
|
|
593
660
|
const heads = [...new Set(running.map((c) => c.head))];
|
|
594
661
|
// — THREE OUTCOMES, NOT TWO. This arm is the one whose entire purpose is to catch a service still
|
|
595
|
-
// running an old bundle after a deploy
|
|
596
|
-
// deploys itself every hour
|
|
662
|
+
// running an old bundle after a deploy. `deploy-test.sh` ran it as the final gate on an instance that
|
|
663
|
+
// deploys itself every hour; that script is retired and its replacement does not invoke this one, so at
|
|
664
|
+
// the time of writing nothing reaches this arm on a deploy — see the note above. It had been degrading to
|
|
665
|
+
// `skip` with a reason that ASSERTED the deployment
|
|
597
666
|
// was not systemd --user — on a box where it is, and where the units are running.
|
|
598
667
|
// · could not look → skip, naming the error. Not probed is not passed.
|
|
599
668
|
// · looked, found none → skip, saying so. A genuinely non-systemd deployment lands here honestly.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
+
//
|
|
5
|
+
// Mints `driver/test/fixtures/reference-strip-backlog.json` — the per-file floor under the repair of
|
|
6
|
+
// the sentences the reference strip broke (tracker issue 185).
|
|
7
|
+
//
|
|
8
|
+
// Run it AFTER repairing lines, never to make a red arm green: the arm it feeds refuses any count that
|
|
9
|
+
// went UP, so re-minting is how a repair is recorded, not how a regression is absorbed.
|
|
10
|
+
//
|
|
11
|
+
// node scripts/mint-reference-strip-backlog.mjs [--check]
|
|
12
|
+
//
|
|
13
|
+
// `--check` re-derives and exits non-zero if the committed table disagrees with the tree, which is what
|
|
14
|
+
// CI runs. Without it, the table is rewritten.
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
|
|
20
|
+
|
|
21
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
const TABLE = join(ROOT, "driver/test/fixtures/reference-strip-backlog.json");
|
|
23
|
+
|
|
24
|
+
const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
|
|
25
|
+
.split("\n").filter(Boolean);
|
|
26
|
+
const minted = censusOf(ROOT, tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
|
|
27
|
+
|
|
28
|
+
if (process.argv.includes("--check")) {
|
|
29
|
+
const have = JSON.parse(readFileSync(TABLE, "utf8"));
|
|
30
|
+
const a = JSON.stringify(have.files), b = JSON.stringify(minted.files);
|
|
31
|
+
if (a !== b || have.total !== minted.total) {
|
|
32
|
+
console.error("reference-strip backlog is STALE against the tree.");
|
|
33
|
+
console.error(` committed total ${have.total}, tree has ${minted.total}`);
|
|
34
|
+
console.error(" re-mint with: node scripts/mint-reference-strip-backlog.mjs");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
console.log(`reference-strip backlog: current — ${minted.total} line(s) still to repair`);
|
|
38
|
+
} else {
|
|
39
|
+
writeFileSync(TABLE, JSON.stringify({ signatures: SIGNATURES.map((s) => s.name), ...minted }, null, 2) + "\n");
|
|
40
|
+
console.log(`minted ${TABLE}: ${minted.total} line(s) across ${Object.keys(minted.files).length} file(s)`);
|
|
41
|
+
}
|
|
@@ -54,11 +54,33 @@ import { cutDecision, versionAtHead, tagsHere } from "./release-cut-decision.mjs
|
|
|
54
54
|
/**
|
|
55
55
|
* Default bound: fifteen minutes at thirty-second steps. Both are arguments so an arm can drive the loop.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* TWENTY-FIVE, RAISED FROM FIFTEEN (tracker issue 247), because this waits for the version pull
|
|
58
|
+
* request's OWN CI and that is what it must clear. Measured over the first three cuts, the wait held
|
|
59
|
+
* 552 s, 622 s and 686 s against a 900 s budget — rising every time, and the thing it waits on is the
|
|
60
|
+
* offline suite, which grows on purpose every time anybody adds an arm. The margin was one slow queue.
|
|
61
|
+
*
|
|
62
|
+
* AND THE FAILURE IS SILENT, which is why the margin has to be generous rather than adequate. Running
|
|
63
|
+
* out is a quiet exit 0 by design — an ordinary "nothing merged" must not read as a fault — so the
|
|
64
|
+
* first time this budget is exceeded, the release is simply stranded behind a green tick. There is no
|
|
65
|
+
* red to notice. The cron floor underneath would eventually publish it, which makes the silence worse
|
|
66
|
+
* rather than better: the version ships late, from a different run, with nobody told why.
|
|
67
|
+
*
|
|
68
|
+
* THE JOB'S `timeout-minutes` MUST EXCEED THIS, with room for the checkout and install above it. A
|
|
69
|
+
* budget smaller than its own longest step cancels the job at the moment it was about to publish, and a
|
|
70
|
+
* cancelled run reads as neither a success nor a failure to anybody scanning the list. The comment here
|
|
71
|
+
* used to say the job was capped at 30 while the job actually said 25 — harmless at a 15 minute wait,
|
|
72
|
+
* and exactly the sort of thing that stops being harmless when somebody raises one number and believes
|
|
73
|
+
* a sentence about the other. `MIN_JOB_MARGIN_MS` is what an arm holds the pair to now.
|
|
74
|
+
*/
|
|
75
|
+
export const WAIT_MS = 25 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How far the job's budget must exceed the wait's: enough for the checkout, the install and the pack
|
|
79
|
+
* that surround it. Five minutes, which is generous against the ~90 s those actually take, because the
|
|
80
|
+
* cost of being wrong in this direction is a cancelled publish and the cost of being wrong in the other
|
|
81
|
+
* is a runner held slightly longer.
|
|
60
82
|
*/
|
|
61
|
-
export const
|
|
83
|
+
export const MIN_JOB_MARGIN_MS = 5 * 60 * 1000;
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* The bound, overridable for one caller only: the dry-run rehearsal.
|
|
@@ -111,6 +133,70 @@ export async function awaitCut({ refresh, read, sleep, waitMs = WAIT_MS, stepMs
|
|
|
111
133
|
|
|
112
134
|
const git = (args) => execFileSync("git", args, { encoding: "utf8" });
|
|
113
135
|
|
|
136
|
+
/**
|
|
137
|
+
* One read of `main`: the version it carries, whether that version is tagged, and WHICH COMMIT said so.
|
|
138
|
+
*
|
|
139
|
+
* THE COMMIT IS READ IN THE SAME PASS AS THE VERSION, and that is the whole point of this function
|
|
140
|
+
* existing rather than being three calls at the call site (tracker issue 238). The job below used to
|
|
141
|
+
* check out `main` by name after this loop returned, so a commit landing in between — one that moves no
|
|
142
|
+
* version, an instrument fix with no note — was packed and published under a number whose changelog
|
|
143
|
+
* never described it. Nothing downstream could see it: the tip check compares VERSIONS, and the version
|
|
144
|
+
* had not moved. Naming the commit here is what makes the two ends of the pipeline talk about the same
|
|
145
|
+
* tree.
|
|
146
|
+
*
|
|
147
|
+
* No fetch happens between the two reads, so they cannot straddle one.
|
|
148
|
+
*
|
|
149
|
+
* EVERY READER IS AN ARGUMENT, and that is not decoration. An arm that injected only `run` still asked
|
|
150
|
+
* real git for the version and the tags, so it answered differently on a box where `main` is tagged than
|
|
151
|
+
* on a runner where the checkout has neither `origin/main` nor tags — green here, red there, for reasons
|
|
152
|
+
* that have nothing to do with what it was checking. This file's own suite header warns about exactly
|
|
153
|
+
* that shape, and one of these arms was written into it anyway.
|
|
154
|
+
*/
|
|
155
|
+
export function versionBumpCommit({ version, ref = "origin/main", run = git, versionAt = versionAtHead, maxWalk = 100 }) {
|
|
156
|
+
const shas = run(["rev-list", "--first-parent", `-n${maxWalk}`, ref]).trim().split("\n").filter(Boolean);
|
|
157
|
+
let answer = null;
|
|
158
|
+
let sawTheChange = false;
|
|
159
|
+
for (const sha of shas) {
|
|
160
|
+
if (versionAt({ ref: sha }) !== version) { sawTheChange = true; break; }
|
|
161
|
+
// KEEP WALKING PAST THE FIRST MATCH. Every commit landing after the bump and before this read also
|
|
162
|
+
// carries the version — that is precisely the class of commit this exists to leave out — so the
|
|
163
|
+
// answer is the OLDEST consecutive one, not the newest.
|
|
164
|
+
answer = sha;
|
|
165
|
+
}
|
|
166
|
+
// A WALK THAT NEVER SAW THE VERSION CHANGE HAS NOT FOUND THE BUMP; it has run out of road. The oldest
|
|
167
|
+
// commit it happened to reach carries the version by coincidence of the window, and publishing that
|
|
168
|
+
// would ship a tree from before the release. An absence is a finding.
|
|
169
|
+
return sawTheChange ? answer : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readMain({ run = git, versionAt = versionAtHead, tags = tagsHere, decide = cutDecision } = {}) {
|
|
173
|
+
const sha = run(["rev-parse", "origin/main"]).trim();
|
|
174
|
+
// CHECKED BEFORE THE VERSION IS READ, so a checkout with no `origin/main` refuses here rather than
|
|
175
|
+
// going on to answer confidently about a tree it could not name. `git rev-parse` prints the name back
|
|
176
|
+
// when it cannot resolve it, so the failure looks like a value rather than like an error.
|
|
177
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
178
|
+
throw new Error(`release-await-cut: \`git rev-parse origin/main\` answered "${sha.slice(0, 80)}", which is `
|
|
179
|
+
+ "not a commit. The publish below checks out what this reports, so a name it cannot resolve must "
|
|
180
|
+
+ "refuse here rather than resolve to something else there.");
|
|
181
|
+
}
|
|
182
|
+
const d = decide({ version: versionAt({ ref: "origin/main" }), tags: tags() });
|
|
183
|
+
// NOT THE TIP. `origin/main` is where the branch points in this pass, and a commit that landed after
|
|
184
|
+
// the version bump carries the same version — so the tip check downstream, which compares versions,
|
|
185
|
+
// passes on exactly the commit that made the tarball disagree with its changelog. The answer is the
|
|
186
|
+
// commit that MOVED the version, which is the version pull request's merge.
|
|
187
|
+
//
|
|
188
|
+
// Only asked when there is something to publish. On the ordinary "nothing merged" answer the tip is
|
|
189
|
+
// what a reader wants recorded, and there is no version whose bump could be looked for.
|
|
190
|
+
if (!d.cut) return { ...d, sha, tip: sha };
|
|
191
|
+
const bump = versionBumpCommit({ version: d.version, run, versionAt });
|
|
192
|
+
if (!bump) {
|
|
193
|
+
throw new Error(`release-await-cut: main carries ${d.version} but no commit in the last 100 could be `
|
|
194
|
+
+ "found that moved it there. The publish below checks out what this reports, and reporting the "
|
|
195
|
+
+ "branch tip instead would publish whatever else has landed since.");
|
|
196
|
+
}
|
|
197
|
+
return { ...d, sha: bump, tip: sha };
|
|
198
|
+
}
|
|
199
|
+
|
|
114
200
|
function main() {
|
|
115
201
|
const out = process.env.GITHUB_OUTPUT;
|
|
116
202
|
const started = Date.now();
|
|
@@ -120,7 +206,7 @@ function main() {
|
|
|
120
206
|
// tags never arrived answers "no tag" about every version there has ever been. That is the one wrong
|
|
121
207
|
// answer this pipeline cannot afford, so it is refreshed on every pass rather than once at checkout.
|
|
122
208
|
refresh: async () => { git(["fetch", "--no-tags", "--prune", "origin", "+refs/heads/main:refs/remotes/origin/main"]); git(["fetch", "--tags", "--force", "origin"]); },
|
|
123
|
-
read: () =>
|
|
209
|
+
read: () => readMain(),
|
|
124
210
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
125
211
|
now: () => Date.now() - started,
|
|
126
212
|
}).catch((e) => {
|
|
@@ -137,7 +223,7 @@ function main() {
|
|
|
137
223
|
+ `(${String(e?.message ?? e).slice(0, 200)}). This is a failure to LOOK, not a finding that `
|
|
138
224
|
+ "nothing was cut — nothing downstream may treat it as one.");
|
|
139
225
|
process.exitCode = 2;
|
|
140
|
-
if (out) appendFileSync(out, "cut=false\nversion=\nlooked=false\n");
|
|
226
|
+
if (out) appendFileSync(out, "cut=false\nversion=\nsha=\nlooked=false\n");
|
|
141
227
|
}).then((r) => {
|
|
142
228
|
if (!r) return;
|
|
143
229
|
const secs = Math.round(r.waitedMs / 1000);
|
|
@@ -148,7 +234,9 @@ function main() {
|
|
|
148
234
|
// `looked` SEPARATES the two negatives above: a loop that ran and found nothing merged, from one
|
|
149
235
|
// that could not read main at all. The publish job below requires `cut=true`, so neither publishes —
|
|
150
236
|
// but a reader deciding whether a release went missing needs to know which of the two happened.
|
|
151
|
-
|
|
237
|
+
// `sha` IS WRITTEN ON BOTH ANSWERS, not only on a cut. It records which commit this loop's verdict is
|
|
238
|
+
// about, so a run that published nothing can still be read back against the tree it looked at.
|
|
239
|
+
if (out) appendFileSync(out, `cut=${r.cut ? "true" : "false"}\nversion=${r.version}\nsha=${r.sha ?? ""}\nlooked=true\n`);
|
|
152
240
|
});
|
|
153
241
|
}
|
|
154
242
|
|
|
@@ -131,7 +131,31 @@ export function checksVerdict({ checkRuns = [], workflowRuns = [], policy = null
|
|
|
131
131
|
* incident, and this is the difference between a false red on the version job — cheap, loud, retried by
|
|
132
132
|
* the next push — and a red that means what it says.
|
|
133
133
|
*/
|
|
134
|
-
|
|
134
|
+
/**
|
|
135
|
+
* This file's own window: 32 attempts at 15 s, or eight minutes.
|
|
136
|
+
*
|
|
137
|
+
* NAMED SO AN ARM CAN COUPLE THE JOB'S BUDGET TO IT (tracker issue 247). The `version` job's
|
|
138
|
+
* `timeout-minutes` has to contain THIS wait, and the arm that claimed to check that compared the job
|
|
139
|
+
* against `release-await-cut.mjs`'s constant instead — a different wait, in a different job, which this
|
|
140
|
+
* job never runs. It held by luck and would have failed the moment that unrelated number was raised.
|
|
141
|
+
*/
|
|
142
|
+
export const CHECK_ATTEMPTS = 32;
|
|
143
|
+
export const CHECK_EVERY_MS = 15_000;
|
|
144
|
+
export const CHECKS_WINDOW_MS = CHECK_ATTEMPTS * CHECK_EVERY_MS;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What the `version` job spends around this wait: checkout, install, the changesets action that cuts the
|
|
148
|
+
* pull request, and the cut/awaited steps either side of it.
|
|
149
|
+
*
|
|
150
|
+
* This margin belongs to THIS file for the same reason the window does. `release-await-cut.mjs` exports a
|
|
151
|
+
* margin of its own with the same name-shape and a different subject — the checkout and install around the
|
|
152
|
+
* `awaited` job's wait. Coupling the version job's budget to that one repeats the very defect this issue
|
|
153
|
+
* closes, one size smaller: raising the other job's margin would move this job's requirement, about a job
|
|
154
|
+
* whose behaviour had not changed. Two jobs, two waits, two margins, and no number shared between them.
|
|
155
|
+
*/
|
|
156
|
+
export const CHECKS_JOB_MARGIN_MS = 4 * 60 * 1000;
|
|
157
|
+
|
|
158
|
+
export async function waitForChecks({ read, sleep, attempts = CHECK_ATTEMPTS, everyMs = CHECK_EVERY_MS } = {}) {
|
|
135
159
|
if (typeof read !== "function") throw new Error("release-version-pr-checks: waitForChecks needs a read()");
|
|
136
160
|
let seen = null;
|
|
137
161
|
for (let i = 1; i <= attempts; i++) {
|
package/scripts/render-check.mjs
CHANGED
|
@@ -64,7 +64,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
64
64
|
import { createServer } from "node:http";
|
|
65
65
|
import { basename, extname } from "node:path";
|
|
66
66
|
import { join, dirname } from "node:path";
|
|
67
|
-
import { fileURLToPath } from "node:url";
|
|
67
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
68
68
|
import { tmpdir } from "node:os";
|
|
69
69
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
70
70
|
import { envFrom } from "../shared/env-aliases.mjs"; // — the name a reader is told to set is the one in force
|
|
@@ -447,12 +447,36 @@ async function measureAtZoom(zoom, deadlineMs = 90_000) {
|
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
/**
|
|
451
|
+
* PURE. Did the in-frame probe post, and if not, which of the two causes does the evidence name?
|
|
452
|
+
*
|
|
453
|
+
* SEPARATED SO EVERY BRANCH CAN BE DRIVEN. The whole finding here is a check that reported three failed
|
|
454
|
+
* measurements about values it never read, and an arm that could only reach this through a real browser on
|
|
455
|
+
* a starved runner would be the same shape one level up: untestable except by luck.
|
|
456
|
+
*
|
|
457
|
+
* `heightMsgs` is the discriminator and was already being collected. It counts messages from the report's
|
|
458
|
+
* OWN height bridge, which travels the same postMessage path as the probe — so non-zero means the frame
|
|
459
|
+
* loaded, ran scripts and reached this shell, and a missing probe is then the probe's problem. Zero means
|
|
460
|
+
* nothing arrived from inside at all, and the probe is not the thing to look at.
|
|
461
|
+
*/
|
|
462
|
+
export function probeVerdict({ innerScrollbar, hOverflowPx, slackPx, heightMsgs = 0 } = {}) {
|
|
463
|
+
const missing = innerScrollbar === "no-probe" || hOverflowPx === "no-probe" || slackPx === null;
|
|
464
|
+
if (!missing) return { measured: true, cause: null, why: null };
|
|
465
|
+
return heightMsgs > 0
|
|
466
|
+
? { measured: false, cause: "probe-only",
|
|
467
|
+
why: `${heightMsgs} height post(s) DID arrive, so the frame loaded and its scripts ran and reached `
|
|
468
|
+
+ "this shell. The probe alone is missing — look at the probe, not at the report." }
|
|
469
|
+
: { measured: false, cause: "nothing-from-inside",
|
|
470
|
+
why: "No message of any kind arrived from inside the frame. The frame did not load, or its scripts "
|
|
471
|
+
+ "did not run. The probe is not the thing to look at." };
|
|
472
|
+
}
|
|
473
|
+
|
|
450
474
|
async function main() {
|
|
451
475
|
const { pool: POOL, built } = resolvePool();
|
|
452
476
|
const runId = pickRun(POOL);
|
|
453
477
|
mkdirSync(WORK, { recursive: true });
|
|
454
478
|
|
|
455
|
-
const { readReport } = await import(join(REPO, "driver", "portal-report.mjs"));
|
|
479
|
+
const { readReport } = await import(pathToFileURL(join(REPO, "driver", "portal-report.mjs")).href);
|
|
456
480
|
const html = readReport(join(POOL, runId), { staff: true, poolRoot: POOL });
|
|
457
481
|
// chrome.css carries 35KB of typography; without it the layout is not the one users see. What must be
|
|
458
482
|
// true is that NO external stylesheet link survives into the measured document — either it was inlined,
|
|
@@ -473,12 +497,40 @@ async function main() {
|
|
|
473
497
|
console.log(" Expect the sideways assertion to FAIL. A clean run here means the instrument is blind.\n");
|
|
474
498
|
}
|
|
475
499
|
let failures = 0;
|
|
500
|
+
// A COULD-NOT-LOOK IS NOT A FAILED MEASUREMENT (tracker issue 239). Counted apart from `failures`
|
|
501
|
+
// because the two mean different things to whoever reads the exit code: 1 says the layout is wrong,
|
|
502
|
+
// 2 says nothing was measured. Merging them is how a starved runner sends a reader to look at CSS.
|
|
503
|
+
let unmeasured = 0;
|
|
476
504
|
|
|
477
505
|
for (const zoom of ZOOMS) {
|
|
478
506
|
const measured = await measureAtZoom(zoom);
|
|
479
507
|
if (!measured.ok) { console.log(` zoom ${zoom}: FAILED — ${measured.why}`); failures++; continue; }
|
|
480
508
|
const r = measured.state;
|
|
481
509
|
|
|
510
|
+
// ── THE THREE ASSERTIONS BELOW READ THE IN-FRAME PROBE, AND IT MAY NEVER HAVE POSTED ────────────
|
|
511
|
+
//
|
|
512
|
+
// `no-probe` and `null` are not measurements that disagreed with the expectation — they are the
|
|
513
|
+
// expectation never being tested. Rendering them as `FAIL … (got "no-probe")` is a check that could
|
|
514
|
+
// not look, reported as a check that looked and disliked what it saw, and it sends a reader to the
|
|
515
|
+
// report's CSS where there is nothing to find. Measured on a starved runner: three assertions failed
|
|
516
|
+
// on a branch whose diff was comments, a documentation line and a new test file.
|
|
517
|
+
//
|
|
518
|
+
// `heightMsgs` separates the two causes and was already being collected. It counts messages from the
|
|
519
|
+
// report's OWN height bridge, which travels the same postMessage path as the probe: non-zero means
|
|
520
|
+
// the frame loaded, ran scripts and reached the parent, so a missing probe is the probe's problem.
|
|
521
|
+
// Zero means nothing from inside arrived at all, and the probe is not the thing to look at.
|
|
522
|
+
const probe = probeVerdict(r);
|
|
523
|
+
if (!probe.measured) {
|
|
524
|
+
unmeasured++;
|
|
525
|
+
console.log(` zoom ${zoom}: (ready by ${r.readyBy}, heights ${r.heightsQuiet}, `
|
|
526
|
+
+ `settled after ${r.settleTries} × 25ms, ${r.heightMsgs} height post(s), ${r.probeMsgs} probe post(s))`);
|
|
527
|
+
console.log(` COULD NOT MEASURE — the in-frame probe never posted, so the report's own scrollbar,`);
|
|
528
|
+
console.log(` its sideways overflow and the frame's slack were not read at zoom ${zoom}.`);
|
|
529
|
+
console.log(` ${probe.why}`);
|
|
530
|
+
console.log(` ── raw state: ${JSON.stringify(r)}`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
482
534
|
const checks = [
|
|
483
535
|
["no border stealing from the frame's viewport", r.borderSteals === 0, r.borderSteals],
|
|
484
536
|
["the report has NO scrollbar of its own", r.innerScrollbar === false, r.innerScrollbar],
|
|
@@ -540,6 +592,13 @@ async function main() {
|
|
|
540
592
|
+ "clean run of this check is not evidence the report is clean.");
|
|
541
593
|
process.exit(caught ? 0 : 1);
|
|
542
594
|
}
|
|
595
|
+
if (unmeasured) {
|
|
596
|
+
// EXIT 2, THE HOUSE MEANING FOR COULD-NOT-LOOK. It still stops CI — nothing is waved through — but it
|
|
597
|
+
// does not claim the layout was measured and found wrong.
|
|
598
|
+
console.error(`\nrender-check: nothing was measured at ${unmeasured} zoom level(s) — the in-frame probe `
|
|
599
|
+
+ `never posted. This is a failure to LOOK, not a finding about the report.`);
|
|
600
|
+
process.exit(2);
|
|
601
|
+
}
|
|
543
602
|
console.log(failures ? `\nrender-check: ${failures} FAILED` : "\nrender-check: all checks passed");
|
|
544
603
|
process.exit(failures ? 1 : 0);
|
|
545
604
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Needs `google-chrome` (on the VM). Same mechanism as render-check.mjs: a page computes its verdict
|
|
23
23
|
// and writes it into <title>, which --dump-dom hands back.
|
|
24
24
|
|
|
25
|
+
import { chromeErrorPage } from "./headless-page.mjs"; // tracker issue 227 — a dumped DOM can be chrome's own error document
|
|
25
26
|
import { execFileSync } from "node:child_process";
|
|
26
27
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
27
28
|
import { tmpdir } from "node:os";
|
|
@@ -63,6 +64,17 @@ function main() {
|
|
|
63
64
|
"--virtual-time-budget=8000", "--dump-dom", `file://${join(work, "verify.html")}`,
|
|
64
65
|
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000 });
|
|
65
66
|
|
|
67
|
+
// ── IS THIS OUR PAGE, OR CHROME'S ERROR PAGE? (tracker issue 227) ───────────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// `--dump-dom` prints whatever chrome ended up showing, and a file it could not read produces the
|
|
70
|
+
// interstitial — which has a `<title>` of its own. This was saved from reporting a pass only by the
|
|
71
|
+
// marker below being specific: the title has to parse as JSON with a `kept` array. That is luck, not
|
|
72
|
+
// a check, and the next person to loosen the parse removes it. Said out loud instead.
|
|
73
|
+
if (chromeErrorPage(out)) {
|
|
74
|
+
console.log(`FAILED — chrome could not open the page and dumped its own error document instead. `
|
|
75
|
+
+ `Nothing below is about the report.`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
66
78
|
const m = /<title>(.*?)<\/title>/s.exec(out);
|
|
67
79
|
if (!m || m[1] === "pending") { console.log("FAILED — the page never reported"); process.exit(1); }
|
|
68
80
|
const kept = JSON.parse(m[1]).kept;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// receives, so it loads the brand webfonts the client's browser loads. Two scripts, two intents; do not
|
|
20
20
|
// "fix" either to match the other.
|
|
21
21
|
import { spawn } from "node:child_process";
|
|
22
|
+
import { assertPageLoaded, cjkCharsIn, cjkVerdict, fontsCovering } from "./headless-page.mjs"; // tracker issue 227 — did chrome open the report, or its own error page?
|
|
22
23
|
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
|
23
24
|
import { tmpdir } from "node:os";
|
|
24
25
|
import { join, dirname, resolve } from "node:path";
|
|
@@ -29,6 +30,9 @@ const OUT = resolve(process.argv[3] ?? join(ROOT, "docs", "assets", "example-rep
|
|
|
29
30
|
const WIDTH = 1280, HEIGHT = 1040;
|
|
30
31
|
// The frame starts here rather than at the top of the document — see the scroll block below.
|
|
31
32
|
const ANCHOR = process.argv.includes("--anchor") ? process.argv[process.argv.indexOf("--anchor") + 1] : "h1";
|
|
33
|
+
// WHAT ONLY A REPORT CARRIES. `h1` is the ANCHOR — where to start the frame — and it is on every HTML
|
|
34
|
+
// page including Chrome's error interstitial, so it cannot also be the proof that this IS a report.
|
|
35
|
+
const MARKER = process.argv.includes("--marker") ? process.argv[process.argv.indexOf("--marker") + 1] : "[data-run-id]";
|
|
32
36
|
|
|
33
37
|
const src = process.argv[2];
|
|
34
38
|
if (!src) { console.error("usage: node scripts/report-screenshot.mjs <report.html> [out.png]"); process.exit(2); }
|
|
@@ -69,6 +73,34 @@ const sessionId = sess.sessionId;
|
|
|
69
73
|
const cmd = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, sessionId, method, params })); });
|
|
70
74
|
|
|
71
75
|
await cmd("Page.enable");
|
|
76
|
+
|
|
77
|
+
// ── IS THIS THE REPORT, OR CHROME'S OWN ERROR PAGE? (tracker issue 227) ──────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// Chrome is launched with the file URL as an ARGUMENT, so there is no navigation response to check and
|
|
80
|
+
// nothing here ever asked. When the file could not be read, Chrome showed `ERR_ACCESS_DENIED` — a page
|
|
81
|
+
// with an `<h1>` — the anchor below resolved against it, the clip was taken, and this exited 0 having
|
|
82
|
+
// written 38 KB of grey error page over the README's example frame. The successful run and the failed
|
|
83
|
+
// one differed in the log by an anchor offset and a font count, neither of which was asserted on.
|
|
84
|
+
//
|
|
85
|
+
// `existsSync` above does not cover it: a file that EXISTS and cannot be READ passes that check and
|
|
86
|
+
// fails in Chrome. So does a file that is readable and is not a report.
|
|
87
|
+
//
|
|
88
|
+
// THE MARKER IS THE RUN ID, not a tag. `h1` is what the error page has; `[data-run-id]` is what only a
|
|
89
|
+
// rendered report has, and naming it in the log is what makes the success line say what it certified
|
|
90
|
+
// rather than "an h1 was found".
|
|
91
|
+
const evaluate = async (expression) => {
|
|
92
|
+
const r = await cmd("Runtime.evaluate", { expression, returnByValue: true });
|
|
93
|
+
return r?.result?.result?.value;
|
|
94
|
+
};
|
|
95
|
+
const loaded = await assertPageLoaded(evaluate, {
|
|
96
|
+
expected: `file://${page}`,
|
|
97
|
+
marker: `document.querySelector(${JSON.stringify(MARKER)})`,
|
|
98
|
+
markerName: `a report element (${MARKER})`,
|
|
99
|
+
what: "report-screenshot",
|
|
100
|
+
});
|
|
101
|
+
if (!loaded.ok) { chrome.kill(); process.exit(1); }
|
|
102
|
+
const runId = await evaluate(`(document.querySelector(${JSON.stringify(MARKER)})?.getAttribute("data-run-id") ?? "")`);
|
|
103
|
+
|
|
72
104
|
// The fonts are the point of allowing the network at all, so wait for them rather than for a fixed
|
|
73
105
|
// sleep: a timer long enough on this box is a timer too short on a slower one, and the failure is a
|
|
74
106
|
// screenshot in the wrong typeface that nobody notices until it is in the README.
|
|
@@ -91,11 +123,39 @@ if (typeof top !== "number") {
|
|
|
91
123
|
console.error(`report-screenshot: no element matched ${JSON.stringify(ANCHOR)} — nothing to anchor the frame to.`);
|
|
92
124
|
chrome.kill(); process.exit(1);
|
|
93
125
|
}
|
|
126
|
+
// ── CAN THIS BOX DRAW WHAT THE PAGE SAYS? (tracker issue 227) ───────────────────────────────────────
|
|
127
|
+
//
|
|
128
|
+
// The default demo product's report carries the mark's native-script renderings — ベンクリ, ベンコリ,
|
|
129
|
+
// ヴェンコリ — and they are load-bearing: the verdict sentence reads "A live Japanese class 9
|
|
130
|
+
// registration reading ベンクリ covers measuring and testing instruments". With no CJK-capable font
|
|
131
|
+
// those render as empty boxes, twice in the captured frame, and nothing said so.
|
|
132
|
+
//
|
|
133
|
+
// The wait for `document.fonts.ready` above was written against exactly this class — "the failure is a
|
|
134
|
+
// screenshot in the wrong typeface that nobody notices until it is in the README" — and solved the
|
|
135
|
+
// TYPEFACE half. This is the WRITING-SYSTEM half, in the same script.
|
|
136
|
+
//
|
|
137
|
+
// REFUSES rather than warns. This writes an image that goes into the README by hand; a warning on a
|
|
138
|
+
// terminal nobody is reading when the file is already written is the shape that produced the defect
|
|
139
|
+
// above it. `--allow-tofu` is there for a reader who genuinely wants the frame anyway and has been told
|
|
140
|
+
// what is in it.
|
|
141
|
+
const pageText = await evaluate("document.body ? document.body.innerText : ''");
|
|
142
|
+
const cjkChars = cjkCharsIn(pageText);
|
|
143
|
+
const glyphs = cjkVerdict({ cjkChars, covering: fontsCovering("ja"),
|
|
144
|
+
sample: (String(pageText).match(/[\u3040-\u30ff\u4e00-\u9fff]{2,8}/u) ?? [])[0] ?? "" });
|
|
145
|
+
if (!glyphs.ok && !process.argv.includes("--allow-tofu")) {
|
|
146
|
+
console.error(`report-screenshot: ${glyphs.why}`);
|
|
147
|
+
console.error(" Pass --allow-tofu to capture it anyway, knowing the frame is missing those glyphs.");
|
|
148
|
+
chrome.kill(); process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
if (!glyphs.ok) console.error(`report-screenshot: WARNING — ${glyphs.why} Capturing anyway (--allow-tofu).`);
|
|
151
|
+
|
|
94
152
|
const y = Math.max(top - 56, 0); // a little air above the title, so the page does not read as cropped
|
|
95
153
|
const shot = await cmd("Page.captureScreenshot", { format: "png", captureBeyondViewport: true,
|
|
96
154
|
clip: { x: 0, y, width: WIDTH, height: HEIGHT, scale: 1 } });
|
|
97
155
|
if (!shot?.result?.data) { console.error(`report-screenshot: chrome returned no image. ${JSON.stringify(shot).slice(0, 300)}`); chrome.kill(); process.exit(1); }
|
|
98
156
|
writeFileSync(OUT, Buffer.from(shot.result.data, "base64"));
|
|
99
|
-
const
|
|
157
|
+
const fonts = await cmd("Runtime.evaluate", { expression: "document.fonts.size + ':' + [...document.fonts].filter(f=>f.status==='loaded').length", returnByValue: true });
|
|
100
158
|
chrome.kill();
|
|
101
|
-
|
|
159
|
+
// NAMES WHAT IT CERTIFIED. "an h1 was found" is true of the error page this used to photograph; the
|
|
160
|
+
// run id is read out of the document and is the thing a reader can check against the report they meant.
|
|
161
|
+
console.log(`report-screenshot: wrote ${OUT} of run ${runId || "(no run id in the page)"} (${WIDTH}x${HEIGHT}, anchor ${JSON.stringify(ANCHOR)} at y=${Math.round(y)}, fonts ${fonts?.result?.result?.value ?? "?"}, ${glyphs.kind})`);
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
// MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one. Run it
|
|
60
60
|
// as a user with `ulimit -v unlimited`. The dbus/UPower errors Chrome prints on a headless box are noise.
|
|
61
61
|
|
|
62
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
62
63
|
import { createServer } from 'node:http'
|
|
63
64
|
import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
64
65
|
import { join, extname, dirname } from 'node:path'
|
|
@@ -284,7 +285,7 @@ const record = {}
|
|
|
284
285
|
|
|
285
286
|
// The shell, once. Everything after this is client-side.
|
|
286
287
|
epoch = 'boot'
|
|
287
|
-
await cmd
|
|
288
|
+
await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
|
|
288
289
|
if (!(await settle())) say(false, 'the shell never went quiet within 12s — it is still requesting')
|
|
289
290
|
const bootPath = await where()
|
|
290
291
|
say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
|
|
@@ -375,7 +376,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
|
|
|
375
376
|
// URL and the SECOND is the Back button, which is the revisit a reader actually performs and the one that
|
|
376
377
|
// runs AppShell's popstate listener rather than its click funnel.
|
|
377
378
|
epoch = 'result:1'
|
|
378
|
-
await cmd
|
|
379
|
+
await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
|
|
379
380
|
const rq1 = await settle()
|
|
380
381
|
const rp1 = await where()
|
|
381
382
|
const rt1 = await screenText()
|