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
package/mcp-server/server.mjs
CHANGED
|
@@ -54,6 +54,13 @@ import { accountRun, accountTrace, accountTimeline, accountFinding, accountFindi
|
|
|
54
54
|
accountWhatIfPlan, accountWhatIfQueued, accountWhatIfResult, CLIENT_FAILURE_NOTE as clientFailureNote } from "./lib/audit-view.mjs";
|
|
55
55
|
import { scrubMarkdown, scrubBody, scrubFrontMatter, scrubCards } from "./lib/scrub.mjs";
|
|
56
56
|
import { evidenceRecords, searchLog, coverageStatement } from "./lib/evidence.mjs";
|
|
57
|
+
// The knockout lane's projections. Every audit tool below branches on `isKnockoutRun` because the
|
|
58
|
+
// clearance projections read artifacts this product does not write, and returned empty rather than saying
|
|
59
|
+
// so (tracker issue 275).
|
|
60
|
+
import {
|
|
61
|
+
isKnockoutRun, knockoutDoc, knockoutArtifacts, knockoutArtifactPath, knockoutFindings,
|
|
62
|
+
knockoutEvidence, knockoutSearches, knockoutCoverage, traceKnockout, notProducedOnThisProduct,
|
|
63
|
+
} from "./lib/knockout.mjs";
|
|
57
64
|
import { instructionsFor } from "./lib/instructions.mjs";
|
|
58
65
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
59
66
|
import { BRAND } from "../shared/brand.mjs"; // — the operator name a client is told to expect, from the tenant seam
|
|
@@ -82,6 +89,18 @@ function mustRun(runId) {
|
|
|
82
89
|
function artifactPath(run, name) {
|
|
83
90
|
const { P, runDir } = run;
|
|
84
91
|
if (name === "status.json") return join(runDir, "status.json");
|
|
92
|
+
// THE KNOCKOUT TABLE IS TERMINAL ON A KNOCKOUT, and both halves of that matter (tracker issue 275).
|
|
93
|
+
//
|
|
94
|
+
// Resolving FIRST is what fixes `report`: a knockout's report.md is written to the POOL and never into
|
|
95
|
+
// the run dir, so the clearance table returned the run dir's own `report` slot — a path that does not
|
|
96
|
+
// exist — and the tool answered `exists: false` about a file sitting on disk.
|
|
97
|
+
//
|
|
98
|
+
// Not FALLING THROUGH is the other half, and it was caught by an arm rather than by design. With a
|
|
99
|
+
// fall-through, `read_artifact narrative` on a knockout resolves against the clearance table, finds the
|
|
100
|
+
// slot, and returns `exists: false` — reporting a document this product never writes as a missing one.
|
|
101
|
+
// That is the defect this issue is about, reappearing one layer down. Returning null instead makes the
|
|
102
|
+
// tool refuse the name and name the artifacts this run actually has.
|
|
103
|
+
if (isKnockoutRun(run)) return knockoutArtifactPath(run, name);
|
|
85
104
|
if (name === "run.jsonl" || name === "telemetry/run.jsonl") return driverDir(runDir, "run.jsonl");
|
|
86
105
|
if (REGISTER_AXES.includes(name)) return P.registerUnit(name);
|
|
87
106
|
// validate the axis against the known set — never let a "registerUnit:../../x" escape the run-dir
|
|
@@ -93,6 +112,10 @@ function artifactPath(run, name) {
|
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
function listArtifacts(run) {
|
|
115
|
+
// The error message a caller sees when a name does not resolve is built from this list, so on a knockout
|
|
116
|
+
// it has to name the knockout's own artifacts — otherwise the tool refuses a name and then suggests
|
|
117
|
+
// eleven documents this product does not write.
|
|
118
|
+
if (isKnockoutRun(run)) return knockoutArtifacts(run).filter((a) => a.exists).map(({ name, file }) => ({ name, file }));
|
|
96
119
|
const { P } = run; const out = [];
|
|
97
120
|
for (const [k, v] of Object.entries(P)) { if (k === "runDir" || typeof v === "function") continue; if (existsSync(v)) out.push({ name: k, file: basename(v) }); }
|
|
98
121
|
for (const ax of REGISTER_AXES) { const p = P.registerUnit(ax); if (existsSync(p)) out.push({ name: `registerUnit:${ax}`, file: basename(p) }); }
|
|
@@ -227,6 +250,28 @@ const tools = {
|
|
|
227
250
|
get_run({ runId }) {
|
|
228
251
|
const run = mustRun(runId);
|
|
229
252
|
const { stages, failover } = getStages(run.runDir);
|
|
253
|
+
// THE LANE DECIDES THE ARTIFACT LIST. Appending REGISTER_AXES unconditionally is what manufactured
|
|
254
|
+
// eleven `exists: false` rows on a product that writes none of those documents — a wall of false
|
|
255
|
+
// negatives, which a reader is entitled to read as a run with nothing on disk.
|
|
256
|
+
if (isKnockoutRun(run)) {
|
|
257
|
+
const doc = knockoutDoc(run);
|
|
258
|
+
const negatives = knockoutFindings(run, { kind: "negatives" }).items;
|
|
259
|
+
return {
|
|
260
|
+
run: runSummary(run),
|
|
261
|
+
product: "knockout",
|
|
262
|
+
stages, failover,
|
|
263
|
+
artifacts: knockoutArtifacts(run),
|
|
264
|
+
coverageSummary: {
|
|
265
|
+
// No ledger EXISTS on this lane, and saying so as a fact beats reporting it as a missing file.
|
|
266
|
+
coverageLedgerPresent: false,
|
|
267
|
+
coverageLedgerNote: "A Knockout search keeps no coverage ledger; get_search_coverage answers "
|
|
268
|
+
+ "from the run's own per-mark record instead.",
|
|
269
|
+
complete: Boolean(doc),
|
|
270
|
+
findings: knockoutFindings(run).items.length,
|
|
271
|
+
negativeResults: negatives.length,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
230
275
|
return {
|
|
231
276
|
run: runSummary(run),
|
|
232
277
|
stages, failover,
|
|
@@ -249,6 +294,18 @@ const tools = {
|
|
|
249
294
|
},
|
|
250
295
|
list_findings({ runId, kind, sourceLayer, group }) {
|
|
251
296
|
const run = mustRun(runId);
|
|
297
|
+
if (isKnockoutRun(run)) {
|
|
298
|
+
// `group` is the clearance report's on-field/off-field/out-of-scope curation. A knockout report has
|
|
299
|
+
// no such sectioning, so the honest answer names that rather than filtering to nothing.
|
|
300
|
+
if (group) {
|
|
301
|
+
return {
|
|
302
|
+
_note: BRIEFING_NOTE, kind: "cards", group, items: [],
|
|
303
|
+
...notProducedOnThisProduct("on-field/off-field/out-of-scope card groups",
|
|
304
|
+
"Call list_findings without `group` for this run's findings, each with its band."),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { _note: BRIEFING_NOTE, ...knockoutFindings(run, { kind, sourceLayer }) };
|
|
308
|
+
}
|
|
252
309
|
if (group) { const cards = loadCards(run.P); return { _note: BRIEFING_NOTE, kind: "cards", group, items: cards.cards.filter((c) => c.group === group), note: cards.note }; }
|
|
253
310
|
return { _note: BRIEFING_NOTE, ...filterFindings(run.P, { kind, sourceLayer }) };
|
|
254
311
|
},
|
|
@@ -256,17 +313,20 @@ const tools = {
|
|
|
256
313
|
// Data, not narrative: no BRIEFING_NOTE rides on these. The projections — and the reasoning about what
|
|
257
314
|
// is evidence and what is method — live in lib/evidence.mjs; nothing is decided here.
|
|
258
315
|
list_evidence({ runId, layer }) {
|
|
259
|
-
const
|
|
316
|
+
const run = mustRun(runId);
|
|
317
|
+
const out = isKnockoutRun(run) ? knockoutEvidence(run) : evidenceRecords(run);
|
|
260
318
|
return layer ? { ...out, records: out.records.filter((r) => r.layer === layer) } : out;
|
|
261
319
|
},
|
|
262
320
|
list_searches({ runId, outcome }) {
|
|
263
|
-
const
|
|
321
|
+
const run = mustRun(runId);
|
|
322
|
+
const out = isKnockoutRun(run) ? knockoutSearches(run) : searchLog(run);
|
|
264
323
|
if (!outcome) return out;
|
|
265
324
|
const searches = out.searches.filter((s) => s.outcome === outcome);
|
|
266
325
|
return { ...out, count: searches.length, searches, totalCount: out.count };
|
|
267
326
|
},
|
|
268
327
|
get_search_coverage({ runId }) {
|
|
269
|
-
|
|
328
|
+
const run = mustRun(runId);
|
|
329
|
+
return isKnockoutRun(run) ? knockoutCoverage(run) : coverageStatement(run);
|
|
270
330
|
},
|
|
271
331
|
get_finding({ runId, id }) {
|
|
272
332
|
const run = mustRun(runId);
|
|
@@ -275,7 +335,12 @@ const tools = {
|
|
|
275
335
|
return f;
|
|
276
336
|
},
|
|
277
337
|
trace({ runId, target, depth, shallow }) {
|
|
278
|
-
|
|
338
|
+
const run = mustRun(runId);
|
|
339
|
+
// The clearance trace's target table is built from STAGE_ORDER, the register axes and the clearance
|
|
340
|
+
// findings spine, so on a knockout it resolved nothing at all — including "verdict" — and its error
|
|
341
|
+
// enumerated fifteen stages, none of them from this lane.
|
|
342
|
+
if (isKnockoutRun(run)) return traceKnockout(run, target, readEvents(run.runDir));
|
|
343
|
+
return trace(run, target, { depth: depth ?? 2, shallow: shallow === true });
|
|
279
344
|
},
|
|
280
345
|
get_telemetry({ runId, stage, axis }) {
|
|
281
346
|
const run = mustRun(runId);
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.3.0-beta.0",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
// per assistant, with a reason, and render no button. Zero buttons is the pass.
|
|
42
42
|
// wired-client the hosted shape: address assistants live, stdio ones honestly absent.
|
|
43
43
|
// wired-staff everything on offer at once — the widest bijection.
|
|
44
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
44
45
|
import { createServer } from 'node:http'
|
|
45
46
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
46
47
|
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
@@ -350,7 +351,7 @@ for (const state of Object.keys(STATES)) {
|
|
|
350
351
|
mints = 0
|
|
351
352
|
await cmd('Page.navigate', { url: 'about:blank' })
|
|
352
353
|
await new Promise((r) => setTimeout(r, 150))
|
|
353
|
-
await cmd
|
|
354
|
+
await navigateOrRefuse(cmd, `${origin}/portal/ai`, { what: 'ai-page-render-check' })
|
|
354
355
|
await new Promise((r) => setTimeout(r, 1400))
|
|
355
356
|
|
|
356
357
|
const access = accessFor(STATES[state])
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Because nothing else in this repo can answer the question. portal-ui runs `node --test` with type
|
|
11
11
|
// stripping and carries no jsdom and no React test renderer — Node cannot import a `.tsx` at all — so the
|
|
12
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
12
13
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
13
14
|
// four source-text tests over Clearances.tsx can prove a string is in a file and nothing more. They
|
|
14
15
|
// cannot see a width, an alignment, or a scrollbar, which is precisely what and are about.
|
|
@@ -318,7 +319,7 @@ const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set
|
|
|
318
319
|
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
319
320
|
|
|
320
321
|
const reload = async () => {
|
|
321
|
-
await cmd
|
|
322
|
+
await navigateOrRefuse(cmd, `${origin}/portal/clearances`, { what: 'clearances-render-check' })
|
|
322
323
|
await new Promise((r) => setTimeout(r, 1800))
|
|
323
324
|
}
|
|
324
325
|
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
31
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
32
|
-
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
33
|
import { join, dirname } from "node:path";
|
|
34
34
|
import { homedir, userInfo } from "node:os";
|
|
35
35
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
@@ -244,7 +244,7 @@ export async function preflight({ home = homedir(), root = ROOT } = {}) {
|
|
|
244
244
|
const unitPath = join(root, "driver", "systemd", "prelim-driver.path");
|
|
245
245
|
const unitText = existsSync(unitPath) ? readFileSync(unitPath, "utf8") : null;
|
|
246
246
|
|
|
247
|
-
const { config } = await import(join(root, "driver", "driver.config.mjs"));
|
|
247
|
+
const { config } = await import(pathToFileURL(join(root, "driver", "driver.config.mjs")).href);
|
|
248
248
|
const queueDirs = config.queueDirs ?? [];
|
|
249
249
|
const watched = unitText == null ? null : watchedQueueDirs(unitText, home);
|
|
250
250
|
|
package/scripts/env-audit.mjs
CHANGED
|
@@ -244,6 +244,26 @@ export const SYSTEM_OWNED = new Set([
|
|
|
244
244
|
// pushed back is right to have made this an explicit decision rather than an omission: they ARE read
|
|
245
245
|
// by product code, and the only honest answers were a row or this list.
|
|
246
246
|
"NO_COLOR", "FORCE_COLOR",
|
|
247
|
+
// ── THE GITHUB ACTIONS RUNTIME (tracker issue 213) ──────────────────────────────────────────────
|
|
248
|
+
//
|
|
249
|
+
// `CI` above is already here for exactly this reason; these two arrived with the release scripts and
|
|
250
|
+
// want the same answer. GitHub sets both INSIDE a workflow run — `GITHUB_OUTPUT` is the step-output
|
|
251
|
+
// file the runner creates, `GITHUB_REPOSITORY` the owner/name of the repository the run belongs to —
|
|
252
|
+
// and `scripts/release-await-cut.mjs`, `release-cut-decision.mjs` and `release-version-pr-checks.mjs`
|
|
253
|
+
// read them there.
|
|
254
|
+
//
|
|
255
|
+
// A row in `.env.example` for `GITHUB_OUTPUT` would tell an operator to set a variable GitHub sets
|
|
256
|
+
// for them, on a machine where it has no meaning at all. That makes the catalogue LESS true, not more
|
|
257
|
+
// — and `INSTALL.md §8` points a new user at that file as the register they must read. The ratchet
|
|
258
|
+
// was right that these are undocumented; the honest answer is the one `NO_COLOR` got, not a row.
|
|
259
|
+
//
|
|
260
|
+
// They surfaced only when the withheld `ops/` bucket was laid back over the public tree, so these
|
|
261
|
+
// arms had had no subject since the cut. Not a regression, and not a documentation gap.
|
|
262
|
+
//
|
|
263
|
+
// NARROW ON PURPOSE — these two names, not a `GITHUB_*` prefix. This list is closed so that a name
|
|
264
|
+
// genuinely ours cannot vanish from the audit by resembling a system name, and a prefix arm here
|
|
265
|
+
// would silently swallow any future `GITHUB_`-prefixed variable this product did come to own.
|
|
266
|
+
"GITHUB_OUTPUT", "GITHUB_REPOSITORY",
|
|
247
267
|
]);
|
|
248
268
|
|
|
249
269
|
/** A file that ships and runs in production, as opposed to one that only ever runs a test. */
|
|
@@ -47,7 +47,7 @@ import { join, dirname, relative, basename } from "node:path";
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
48
48
|
import { driverDir } from "../shared/driver-dir.mjs"; //
|
|
49
49
|
import { tmpdir } from "node:os";
|
|
50
|
-
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
51
51
|
|
|
52
52
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
53
53
|
|
|
@@ -123,7 +123,7 @@ const FROZEN_DIRS = [
|
|
|
123
123
|
//
|
|
124
124
|
// research/ IS REQUIRED AND THE PROOF IS WHAT FOUND IT. publish/knockout.mjs
|
|
125
125
|
// traces every finding citation back to the run's own research payload — `research/<mark>.md`, read from
|
|
126
|
-
// the workspace (knockout.mjs:317) — and REFUSES the publish when a citation cannot be traced. The first
|
|
126
|
+
// the workspace (publish/knockout.mjs:317) — and REFUSES the publish when a citation cannot be traced. The first
|
|
127
127
|
// knockout freeze copied nine files, left research/ behind, and the republish proof threw:
|
|
128
128
|
//
|
|
129
129
|
// knockout publish REFUSED: 2 finding citation(s) could not be traced to this run's own research
|
|
@@ -506,7 +506,7 @@ const poolFull = join(scratch, "full");
|
|
|
506
506
|
const poolFrozen = join(scratch, "frozen");
|
|
507
507
|
const meta = { runId, codename, customerKey, template };
|
|
508
508
|
|
|
509
|
-
const { republishRun } = await import(join(REPO, "driver", "publish", "report-registry.mjs"));
|
|
509
|
+
const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publish", "report-registry.mjs")).href);
|
|
510
510
|
const publishInto = async (pool, dir) => {
|
|
511
511
|
mkdirSync(pool, { recursive: true });
|
|
512
512
|
return republishRun({ runId, meta, pool, poolUrl: "", runDir: dir, skipRegen: true });
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// headless-page.mjs — did the browser open the page we asked for, or something of its own?
|
|
5
|
+
//
|
|
6
|
+
// (and, since tracker issue 227 criteria 3-4, whether this box can draw what that page says)
|
|
7
|
+
//
|
|
8
|
+
// ── WHY THIS EXISTS (tracker issue 227) ─────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Seven scripts drive headless Chrome and none of them asked. They cannot ask the obvious way: Chrome is
|
|
11
|
+
// launched with the file URL as a COMMAND-LINE ARGUMENT, so there is no navigation call whose response
|
|
12
|
+
// could be checked. The page simply becomes whatever Chrome ends up showing.
|
|
13
|
+
//
|
|
14
|
+
// What it ends up showing, when the file cannot be read, is Chrome's own interstitial — and that page
|
|
15
|
+
// has an `<h1>`. `report-screenshot.mjs` asserted `document.querySelector("h1")` and nothing else, so it
|
|
16
|
+
// photographed `ERR_ACCESS_DENIED`, wrote 38 KB of grey error page over the README's example frame, and
|
|
17
|
+
// exited 0. Measured: the successful run and the failed one differed in the log by an anchor offset and
|
|
18
|
+
// a font count, neither of which was asserted on.
|
|
19
|
+
//
|
|
20
|
+
// Six of the seven were saved only by an unrelated content assertion that happened to be specific enough.
|
|
21
|
+
// That is luck, and it is per-script: the next assertion somebody writes to be tolerant removes it.
|
|
22
|
+
//
|
|
23
|
+
// ── THE DISCRIMINATOR IS THE ADDRESS, NOT THE CONTENT ───────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// `location.href` reads `chrome-error://chromewebdata/` on the interstitial and the requested `file://`
|
|
26
|
+
// URL on a real load. It is the one thing the error page cannot fake, because it is not part of the
|
|
27
|
+
// document — an `<h1>`, a `<title>`, a body class are all things an arbitrary HTML page can carry, and an
|
|
28
|
+
// error page IS an arbitrary HTML page.
|
|
29
|
+
//
|
|
30
|
+
// A MARKER IS STILL REQUIRED, because the address only proves Chrome opened the file. A file that exists,
|
|
31
|
+
// is readable, and is not the artefact this script is about would pass the address check — an empty
|
|
32
|
+
// render, a stale page, a half-written document. So the caller names one thing that only its own artefact
|
|
33
|
+
// carries, and the verdict says which of the two failed.
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
|
|
37
|
+
/** Chrome's own error pages live under this scheme. Nothing a real document is served from does. */
|
|
38
|
+
export const CHROME_ERROR_SCHEME = "chrome-error:";
|
|
39
|
+
/** What Chrome shows before it has navigated. Not a document, and not a wrong one. */
|
|
40
|
+
export const START_PAGE = "about:blank";
|
|
41
|
+
/** How long `assertPageLoaded` will wait for the browser to leave its start page. */
|
|
42
|
+
export const NAVIGATION_GRACE_MS = 5000;
|
|
43
|
+
/** How often it asks, inside that grace. */
|
|
44
|
+
export const NAVIGATION_POLL_MS = 100;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PURE. Given what the page says about itself, is it the document we asked for?
|
|
48
|
+
*
|
|
49
|
+
* Separated from the evaluation so every branch can be driven — the whole finding here is a check that
|
|
50
|
+
* returned a verdict about a page it never identified, and an arm that could only exercise this through
|
|
51
|
+
* a real browser would be the same shape one level up.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} href `location.href` as the page reports it
|
|
54
|
+
* @param {string} expected the `file://` URL the caller asked Chrome to open
|
|
55
|
+
* @param {boolean|null} marker did the caller's own content marker resolve? `null` means not asked
|
|
56
|
+
* @param {string} markerName what the marker is, for the message
|
|
57
|
+
*/
|
|
58
|
+
export function pageVerdict({ href = "", expected = "", marker = null, markerName = "the page's own content", errorText = null } = {}) {
|
|
59
|
+
const said = String(href ?? "");
|
|
60
|
+
// ── THE TWO SHAPES IN THIS REPOSITORY ───────────────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// Four of these scripts call `Page.navigate`, which RETURNS an `errorText` on failure and which none
|
|
63
|
+
// of them read. Three launch Chrome with the URL as an argument and have no response at all. One
|
|
64
|
+
// verdict serves both: `errorText` is checked when the caller has one, and the address is checked
|
|
65
|
+
// either way — because a failed `Page.navigate` can also leave the page at `about:blank`, which no
|
|
66
|
+
// error text describes and which a content check reads as an empty document rather than a failure.
|
|
67
|
+
if (errorText) {
|
|
68
|
+
return { ok: false, kind: "navigate-failed",
|
|
69
|
+
why: `chrome refused to navigate to ${expected}: ${errorText}. The page is whatever it was showing `
|
|
70
|
+
+ "before, so anything measured now is about the wrong document." };
|
|
71
|
+
}
|
|
72
|
+
if (!said) {
|
|
73
|
+
return { ok: false, kind: "silent",
|
|
74
|
+
why: "the page reported no address at all, so nothing here identifies what was captured. A "
|
|
75
|
+
+ "screenshot taken now certifies an unknown document." };
|
|
76
|
+
}
|
|
77
|
+
if (said.startsWith(CHROME_ERROR_SCHEME)) {
|
|
78
|
+
return { ok: false, kind: "chrome-error",
|
|
79
|
+
why: `chrome could not open ${expected} and is showing ITS OWN error page (${said}). Whatever was `
|
|
80
|
+
+ "captured is Chrome's interstitial, not the artefact — and that page carries an `<h1>`, a "
|
|
81
|
+
+ "`<title>` and a body, so a content check alone reads it as a success." };
|
|
82
|
+
}
|
|
83
|
+
// ── THE BROWSER'S START PAGE IS NOT A WRONG DOCUMENT (tracker issue 273) ────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// `about:blank` is what Chrome shows before it has navigated anywhere. Reaching the check below, it
|
|
86
|
+
// compares unequal to the expected URL and was reported as `wrong-document` — "a redirect, a stale tab
|
|
87
|
+
// or a second page target" — which is a finding about a page. It is not one. Nothing was ever loaded,
|
|
88
|
+
// so nothing about the target document has been measured either way.
|
|
89
|
+
//
|
|
90
|
+
// This mattered because it is what a LOADED BOX produces: three of these scripts launch Chrome with the
|
|
91
|
+
// URL as a command-line argument and cannot wait for a navigation event, so under load the address is
|
|
92
|
+
// read before the browser has moved. A real defect and a slow browser then arrived as the same message,
|
|
93
|
+
// and the arms that exist to catch a wrong page were the ones that fired.
|
|
94
|
+
if (said === START_PAGE || said.startsWith(`${START_PAGE}?`) || said.startsWith(`${START_PAGE}#`)) {
|
|
95
|
+
return { ok: false, kind: "not-navigated",
|
|
96
|
+
why: `chrome is on its start page (${said}) and never reached ${expected}. Nothing about that `
|
|
97
|
+
+ "document has been measured, so this is not a finding about the page. TWO THINGS LOOK LIKE "
|
|
98
|
+
+ "THIS and the address cannot tell them apart: a navigation that failed without saying so, and "
|
|
99
|
+
+ "one that had not happened yet. That is why the caller waits before asking — a verdict of this "
|
|
100
|
+
+ "kind means it waited and the browser never left the start page." };
|
|
101
|
+
}
|
|
102
|
+
// NORMALISED ON BOTH SIDES. Chrome resolves and percent-encodes a `file://` argument, so a raw string
|
|
103
|
+
// comparison fails on a path with a space and reports "the wrong document" about the right one.
|
|
104
|
+
const norm = (u) => { try { return new URL(u).href; } catch { return String(u); } };
|
|
105
|
+
if (expected && norm(said) !== norm(expected)) {
|
|
106
|
+
return { ok: false, kind: "wrong-document",
|
|
107
|
+
why: `chrome is showing ${said}, and this run asked for ${expected}. A redirect, a stale tab or a `
|
|
108
|
+
+ "second page target — whichever it is, the frame is not of the document this script names." };
|
|
109
|
+
}
|
|
110
|
+
if (marker === false) {
|
|
111
|
+
return { ok: false, kind: "not-the-artefact",
|
|
112
|
+
why: `chrome opened ${said} and ${markerName} is not in it. The address is right and the CONTENT is `
|
|
113
|
+
+ "not what this script is about — an empty render, a stale file, or a document half written." };
|
|
114
|
+
}
|
|
115
|
+
if (marker === null) {
|
|
116
|
+
return { ok: false, kind: "unmarked",
|
|
117
|
+
why: "no content marker was asked for, so this run proves only that a file opened. Name one thing "
|
|
118
|
+
+ "the artefact carries and nothing else does — an address alone cannot tell an artefact from any "
|
|
119
|
+
+ "other readable file." };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, kind: "loaded", why: `${said} is open and ${markerName} is in it.` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ask the live page, then judge. `evaluate` runs an expression and returns its value.
|
|
126
|
+
*
|
|
127
|
+
* The caller passes its own CDP `Runtime.evaluate` wrapper, because each of these scripts built its own
|
|
128
|
+
* handshake before this file existed and rewriting seven of them to share one is a bigger change than the
|
|
129
|
+
* defect warrants. What they must share is the QUESTION.
|
|
130
|
+
*/
|
|
131
|
+
export async function assertPageLoaded(evaluate, { expected, marker = null, markerName = "the page's own content", what = "this page", errorText = null,
|
|
132
|
+
graceMs = NAVIGATION_GRACE_MS, pollMs = NAVIGATION_POLL_MS,
|
|
133
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now() } = {}) {
|
|
134
|
+
// ── WAIT FOR THE BROWSER TO LEAVE ITS START PAGE, THEN JUDGE (tracker issue 273) ─────────────────
|
|
135
|
+
//
|
|
136
|
+
// Three of these scripts launch Chrome with the URL as an argument and get no response to wait on, so
|
|
137
|
+
// the first read of `location.href` can land before the browser has moved. On an idle box it never
|
|
138
|
+
// does; under a full parallel suite it did, repeatedly, and the arms reported the start page as a
|
|
139
|
+
// wrong document.
|
|
140
|
+
//
|
|
141
|
+
// BOUNDED, AND THE BOUND IS THE POINT. This waits for the browser to become ready — it does not wait
|
|
142
|
+
// for the page to become correct. If the address is anything other than the start page it is judged
|
|
143
|
+
// immediately, so a genuinely wrong document still fails on the first read and fails as fast as it did
|
|
144
|
+
// before. Only the "nothing has happened yet" case costs time, and only up to the grace.
|
|
145
|
+
//
|
|
146
|
+
// When the grace runs out the verdict is `not-navigated`, which is a could-not-look and says so.
|
|
147
|
+
// Deadline arithmetic is the caller's to drive: `sleep` and `now` are injected so the exhausted path
|
|
148
|
+
// can be exercised without a browser and without waiting.
|
|
149
|
+
let href = await evaluate("location.href");
|
|
150
|
+
if (String(href ?? "") === START_PAGE) {
|
|
151
|
+
const until = now() + graceMs;
|
|
152
|
+
while (String(href ?? "") === START_PAGE && now() < until) {
|
|
153
|
+
await sleep(pollMs);
|
|
154
|
+
href = await evaluate("location.href");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const found = marker == null ? null : Boolean(await evaluate(`Boolean(${marker})`));
|
|
158
|
+
const verdict = pageVerdict({ href, expected, marker: found, markerName, errorText });
|
|
159
|
+
if (!verdict.ok) {
|
|
160
|
+
console.error(`${what}: ${verdict.why}`);
|
|
161
|
+
return { ...verdict, href };
|
|
162
|
+
}
|
|
163
|
+
return { ...verdict, href };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Navigate, and refuse if chrome says it could not.
|
|
168
|
+
*
|
|
169
|
+
* `Page.navigate` RETURNS `{ frameId, loaderId, errorText }`, and every caller in this repository threw
|
|
170
|
+
* the result away. So a portal that was not listening, a DNS failure, a refused connection — each left
|
|
171
|
+
* the page showing whatever it had before, and the assertions that followed measured the previous page
|
|
172
|
+
* or an empty one. Six of these scripts were saved from reporting a pass by an unrelated content
|
|
173
|
+
* assertion that happened to be specific enough; that is luck, per script, and the next person to write
|
|
174
|
+
* a more tolerant assertion removes it.
|
|
175
|
+
*
|
|
176
|
+
* THROWS rather than returning a verdict, because there is nothing sensible for a caller to do with a
|
|
177
|
+
* navigation that did not happen, and the alternative — a boolean somebody forgets to read — is the
|
|
178
|
+
* shape this fixes.
|
|
179
|
+
*/
|
|
180
|
+
export async function navigateOrRefuse(cmd, url, { what = "this page" } = {}) {
|
|
181
|
+
const r = await cmd("Page.navigate", { url });
|
|
182
|
+
const errorText = r?.result?.errorText ?? r?.errorText ?? null;
|
|
183
|
+
if (errorText) {
|
|
184
|
+
const verdict = pageVerdict({ errorText, expected: url });
|
|
185
|
+
throw new Error(`${what}: ${verdict.why}`);
|
|
186
|
+
}
|
|
187
|
+
return r;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Does this dumped DOM belong to chrome's own error page?
|
|
192
|
+
*
|
|
193
|
+
* For the callers that use `--dump-dom` rather than CDP: there is no `location.href` to ask, only the
|
|
194
|
+
* bytes chrome printed. Chrome's interstitial is recognisable by the error-code element it always
|
|
195
|
+
* carries and by its `neterror`/`chrome-error` markers — none of which a document we authored has.
|
|
196
|
+
*
|
|
197
|
+
* DELIBERATELY NARROW. A page that merely CONTAINS the words "error" or "denied" is not this; a report
|
|
198
|
+
* about a refused search would say both. What is matched is chrome's own furniture.
|
|
199
|
+
*/
|
|
200
|
+
export function chromeErrorPage(dom = "") {
|
|
201
|
+
const t = String(dom);
|
|
202
|
+
return /chrome-error:\/\//.test(t)
|
|
203
|
+
|| /id="?main-frame-error"?/.test(t)
|
|
204
|
+
|| /jstcache=|<body[^>]*\bid="?neterror"?/.test(t);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── CAN THIS BOX DRAW WHAT THE PAGE SAYS? (tracker issue 227, criteria 3 and 4) ──────────────────────
|
|
208
|
+
//
|
|
209
|
+
// The default demo product is a full-country search, and its report carries the mark's native-script
|
|
210
|
+
// renderings — ベンクリ, ベンコリ, ヴェンコリ. They are LOAD-BEARING: the verdict sentence reads "A live
|
|
211
|
+
// Japanese class 9 registration reading ベンクリ covers measuring and testing instruments".
|
|
212
|
+
//
|
|
213
|
+
// On a box with no CJK-capable font those render as `□□□`, twice in the captured frame, and nothing
|
|
214
|
+
// says so. The script's own comment already names this class of failure — "the failure is a screenshot
|
|
215
|
+
// in the wrong typeface that nobody notices until it is in the README" — and it waits for
|
|
216
|
+
// `document.fonts.ready` to prevent it. That solved the TYPEFACE problem and left the WRITING-SYSTEM one,
|
|
217
|
+
// in the same script, with the same failure mode.
|
|
218
|
+
//
|
|
219
|
+
// ASKED OF FONTCONFIG, not of the page. `document.fonts` reports the faces a page ASKED for and got; it
|
|
220
|
+
// says nothing about whether the glyphs exist. `fc-list :lang=ja` answers the question actually being
|
|
221
|
+
// asked — can anything on this box draw these characters — and it is the same source a reader would
|
|
222
|
+
// check by hand.
|
|
223
|
+
|
|
224
|
+
/** Han, Hiragana, Katakana, Hangul — the ranges a Latin-only font set leaves as tofu. */
|
|
225
|
+
const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/gu;
|
|
226
|
+
|
|
227
|
+
/** How many characters in this text need a CJK-capable font. */
|
|
228
|
+
export function cjkCharsIn(text = "") {
|
|
229
|
+
return (String(text).match(CJK) ?? []).length;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* PURE. Given what the page needs and what the box has, is the frame trustworthy?
|
|
234
|
+
*
|
|
235
|
+
* `covering` is the number of fonts fontconfig reports for the writing system — `null` means the caller
|
|
236
|
+
* could not ask, which is NOT zero: a box where `fc-list` is missing is a box this cannot judge, and
|
|
237
|
+
* reporting it as "no coverage" would refuse a machine that may be fine.
|
|
238
|
+
*/
|
|
239
|
+
export function cjkVerdict({ cjkChars = 0, covering = 0, sample = "" } = {}) {
|
|
240
|
+
if (!cjkChars) return { ok: true, kind: "no-cjk", why: "the page carries no CJK characters." };
|
|
241
|
+
if (covering === null) {
|
|
242
|
+
return { ok: true, kind: "unknown-coverage",
|
|
243
|
+
why: `the page carries ${cjkChars} CJK character(s) and this run could not ask fontconfig what can `
|
|
244
|
+
+ "draw them. Not a refusal — a box that cannot be asked is not a box known to be missing fonts — "
|
|
245
|
+
+ "but the frame is unverified on that point." };
|
|
246
|
+
}
|
|
247
|
+
if (covering > 0) {
|
|
248
|
+
return { ok: true, kind: "covered",
|
|
249
|
+
why: `the page carries ${cjkChars} CJK character(s) and ${covering} installed font(s) cover them.` };
|
|
250
|
+
}
|
|
251
|
+
return { ok: false, kind: "tofu",
|
|
252
|
+
why: `the page carries ${cjkChars} CJK character(s)${sample ? ` (${sample})` : ""} and NO installed `
|
|
253
|
+
+ "font can draw them — `fc-list :lang=ja` reports none. They render as empty boxes, and the frame "
|
|
254
|
+
+ "would go out with the mark's own native-script rendering missing. Install a CJK font (on Debian "
|
|
255
|
+
+ "and Ubuntu: `fonts-noto-cjk`), or set XDG_DATA_HOME to a directory holding one." };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* How many installed fonts cover a writing system, per fontconfig — or `null` if we could not ask.
|
|
260
|
+
*
|
|
261
|
+
* `null` IS THE POINT. `fc-list` missing, or a fontconfig that errors, is a box this cannot judge, and
|
|
262
|
+
* collapsing that into 0 would refuse a machine that may be perfectly able to draw the page. The caller
|
|
263
|
+
* treats the two differently, which is the whole reason this returns three values and not a number.
|
|
264
|
+
*/
|
|
265
|
+
export function fontsCovering(lang = "ja", { run } = {}) {
|
|
266
|
+
try {
|
|
267
|
+
const out = run
|
|
268
|
+
? run(["-f", "%{file}\\n", `:lang=${lang}`])
|
|
269
|
+
: execFileSync("fc-list", ["-f", "%{file}\\n", `:lang=${lang}`], { encoding: "utf8", timeout: 20_000 });
|
|
270
|
+
return String(out).split("\n").map((l) => l.trim()).filter(Boolean).length;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Exits non-zero on the first state that fails to draw, scrolls sideways, or renders a pip count that
|
|
19
19
|
// contradicts the run it is drawn from.
|
|
20
20
|
|
|
21
|
+
import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
|
|
21
22
|
import { createServer } from 'node:http'
|
|
22
23
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
23
24
|
import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
@@ -377,7 +378,7 @@ for (const [name, spec] of Object.entries(STATES)) {
|
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
await evalIn(`window.__renderCheckDoc = 1`)
|
|
380
|
-
await cmd
|
|
381
|
+
await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'home-render-check' })
|
|
381
382
|
if (!await homeReady('navigate')) continue
|
|
382
383
|
await evalIn(`localStorage.setItem('cordillera-theme', ${JSON.stringify(theme)})`)
|
|
383
384
|
await evalIn(`window.__renderCheckDoc = 1`)
|