clearotron 0.3.2-beta.11 → 0.3.2-beta.13
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 +1 -1
- package/CONTRIBUTING.md +6 -5
- package/INSTALL.md +3 -4
- package/README.md +2 -1
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/example.mjs +12 -1
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +23 -3
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +18 -6
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +6 -6
- package/docs/DELIVERY.md +3 -3
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +3 -3
- package/docs/RELEASES.md +1 -1
- package/docs/SECURITY.md +1 -1
- package/docs/architecture/04-configuration-reference.md +4 -4
- package/docs/architecture/05-config-governance.md +10 -10
- package/docs/architecture/06-operations-runbook.md +3 -3
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +2 -2
- package/docs/architecture/09-security-and-data.md +1 -1
- package/docs/decisions/0002-no-dark-functionality.md +1 -1
- package/docs/decisions/0006-what-the-public-repository-carries.md +3 -3
- package/driver/CHANGELOG.md +29 -0
- package/driver/ask-ledger.mjs +1 -1
- package/driver/band-shape.mjs +1 -1
- package/driver/bundled-demos.mjs +2 -2
- package/driver/card-budget.mjs +2 -2
- package/driver/case-law-ledger.mjs +2 -2
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-e3-backlog.mjs +13 -13
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/coverage-form.mjs +2 -2
- package/driver/demo-container.mjs +26 -2
- package/driver/disposition-tool.mjs +2 -2
- package/driver/engine/CONTRACT.md +3 -3
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/engine/openai-agent.mjs +2 -2
- package/driver/findings-model.mjs +21 -6
- package/driver/gateway.mjs +1 -1
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +27 -1
- package/driver/pipeline.mjs +4 -4
- package/driver/placement-union.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-report.mjs +25 -3
- package/driver/portal-service.mjs +25 -13
- package/driver/predelivery-lint.mjs +9 -4
- package/driver/progress.mjs +37 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +6 -6
- package/driver/record-discard.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-count.mjs +1 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/report-card-record.mjs +2 -2
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/roster-verdict.mjs +2 -2
- package/driver/search-policy.mjs +1 -1
- package/driver/skeptic-record.mjs +1 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +72 -24
- package/driver/systemd/README.md +1 -1
- package/driver/unit-inventory.mjs +2 -2
- package/driver/verify-knockout.mjs +0 -27
- package/driver/verify.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/CONNECT.md +8 -8
- package/mcp-server/lib/runs.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/serve.mjs +27 -0
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/free-tier/src/capabilities.js +2 -2
- package/providers/jx/src/core.js +3 -3
- package/providers/jx/src/turn-envelope.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/README.md +1 -1
- package/providers/signa/src/capabilities.js +2 -2
- package/providers/signa/src/core.js +2 -2
- package/providers/uspto-local/src/core.js +1 -1
- package/providers/uspto-local/src/sync.js +1 -1
- package/scripts/README.md +2 -6
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-anchor-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +3 -3
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +1 -1
- package/scripts/env-audit.mjs +7 -1
- package/scripts/env-classify.mjs +1 -1
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/release-artifact-seal.mjs +2 -2
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +15 -8
- package/scripts/strip-tracker-citations.mjs +4 -4
- package/scripts/test-run.mjs +108 -6
- package/shared/browser-temp-root.mjs +10 -3
- package/shared/client-door.mjs +38 -6
- package/shared/connect-clients.mjs +2 -0
- package/shared/identifier-scan.mjs +2 -2
- package/shared/invocation.mjs +1 -1
- package/shared/parent-watch.mjs +33 -0
- package/shared/reference-guard-classes.mjs +5 -3
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +22 -3
- package/shared/stdio-connect.mjs +39 -18
- package/shared/writing-standard-classes.mjs +2 -3
package/scripts/repo-writes.mjs
CHANGED
|
@@ -119,3 +119,45 @@ export function explainRepoWrites(rows) {
|
|
|
119
119
|
" touching the tree to tell the two apart.)",
|
|
120
120
|
];
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// ── the home the run executes as ────────────────────────────────────────────────────────────────────
|
|
124
|
+
//
|
|
125
|
+
// THE SAME RULE, ONE FOLDER OUT. The suite is documented as offline with no side effects, and a fresh
|
|
126
|
+
// clone's run left `~/trademark/telemetry/trademark-mcp-access.jsonl` behind (measured 2026-09-19, one
|
|
127
|
+
// line per run): a test spawned the stdio server with the real home, and `doctor` on that machine then
|
|
128
|
+
// reported the client door's access log as being written. These are the product's own folders under a
|
|
129
|
+
// home: the pool, workspace, queue and telemetry, and the settings, records and revocation list. A run
|
|
130
|
+
// leaves them as it found them. The rest of a home — npm's cache, a browser's profile — belongs to the
|
|
131
|
+
// tools a run uses, and is theirs to write.
|
|
132
|
+
|
|
133
|
+
/** The product's own folders under a home, relative to it. */
|
|
134
|
+
export const HOME_DATA = Object.freeze(["trademark", join(".config", "clearotron")]);
|
|
135
|
+
|
|
136
|
+
/** Every path under the home's product folders, stamped. A folder that does not exist is not walked. */
|
|
137
|
+
export function snapshotHome(home) {
|
|
138
|
+
const out = new Map();
|
|
139
|
+
for (const rel of HOME_DATA) {
|
|
140
|
+
const p = join(resolve(home), rel);
|
|
141
|
+
let isDir = false;
|
|
142
|
+
try { isDir = lstatSync(p).isDirectory(); } catch { continue; }
|
|
143
|
+
if (!isDir) { out.set(p, "not-a-directory"); continue; }
|
|
144
|
+
out.set(p, "dir");
|
|
145
|
+
for (const [k, v] of snapshotRepo(p)) out.set(k, v);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a reader is told when a run wrote under the home's product folders. */
|
|
151
|
+
export function explainHomeWrites(rows, home) {
|
|
152
|
+
return [
|
|
153
|
+
"",
|
|
154
|
+
`[test-run] THIS RUN WROTE UNDER THE HOME IT RAN AS (${home}), in the product's own folders:`,
|
|
155
|
+
...rows,
|
|
156
|
+
"",
|
|
157
|
+
" A test that starts a product command hands it a HOME under its own temp directory. Without one, the",
|
|
158
|
+
" command reads and writes the pool, queue, telemetry and settings of whoever runs the suite.",
|
|
159
|
+
"",
|
|
160
|
+
" (A service of this account writing there while the run was in flight prints this too. Run again",
|
|
161
|
+
" with HOME set to an empty temp directory to tell the two apart.)",
|
|
162
|
+
];
|
|
163
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
// report-sections-render-check.mjs — the report's section strip sits under the Reads and shows how far the
|
|
4
|
+
// reader has got, measured in a real browser.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/report-sections-render-check.mjs [--shots <dir>] [--keep]
|
|
7
|
+
//
|
|
8
|
+
// THE DEFECT, from the owner on the test instance (2026-09-19): scrolling the report never moved the strip,
|
|
9
|
+
// so "Summary" stayed marked whatever was in view; and with sixteen reads the strip sat BESIDE the Reads, the
|
|
10
|
+
// reads wrapping into two ragged columns while the strip floated at mid-height. His ruling: the strip goes
|
|
11
|
+
// under the Reads on its own full-width row, and it shows PROGRESS. Every section reached so far is marked,
|
|
12
|
+
// none at the top, and scrolling back up un-marks them the same way. A click still jumps.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS MEASURED: a real renderer's report (the demo's full country search, put through the demo's own
|
|
15
|
+
// publisher and the portal's own embed preparation) inside the real portal build, with twelve reads of one
|
|
16
|
+
// mark. At 1440 and 400 wide, in both themes: the strip's row is under the Reads row; then the page is
|
|
17
|
+
// scrolled so each section's start reaches the bottom of the pinned header, down and back up, and the
|
|
18
|
+
// number of filled entries must equal the number of sections reached. Where each section starts is read
|
|
19
|
+
// from INSIDE the frame, independently of what the document reports to the portal. Last, one entry is
|
|
20
|
+
// pressed and the page must jump to that section and mark it.
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
22
|
+
import { createServer } from 'node:http'
|
|
23
|
+
import { readFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { join, dirname, extname, normalize } from 'node:path'
|
|
25
|
+
import { tmpdir } from 'node:os'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { reapOnExit } from '../shared/reap-on-exit.mjs'
|
|
28
|
+
import { browserRun } from '../shared/browser-temp-root.mjs'
|
|
29
|
+
import { prepareReportForEmbed } from '../driver/portal-report.mjs'
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
32
|
+
const ROOT = join(HERE, '..')
|
|
33
|
+
const DIST = join(ROOT, 'portal-ui', 'dist')
|
|
34
|
+
const keep = process.argv.includes('--keep')
|
|
35
|
+
const argValue = (flag) => (process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : null)
|
|
36
|
+
const shotsDir = argValue('--shots')
|
|
37
|
+
if (!existsSync(join(DIST, 'index.html'))) { console.error(`no build at ${DIST} — run: npm run build:ui`); process.exit(2) }
|
|
38
|
+
if (shotsDir) mkdirSync(shotsDir, { recursive: true })
|
|
39
|
+
|
|
40
|
+
// ── A REAL REPORT ───────────────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
const pool = mkdtempSync(join(tmpdir(), 'sections-check-'))
|
|
42
|
+
const published = spawnSync(process.execPath, [join(ROOT, 'bin', 'example.mjs'), '--once', '--pool', pool, '--product', 'full-country-search'], { encoding: 'utf8' })
|
|
43
|
+
if (published.status !== 0) { console.error(`the demo publisher failed:\n${published.stderr || published.stdout}`); process.exit(2) }
|
|
44
|
+
const runDir = readdirSync(pool).map((d) => join(pool, d)).find((d) => existsSync(join(d, 'report.html')))
|
|
45
|
+
if (!runDir) { console.error(`the demo publisher wrote no report under ${pool}`); process.exit(2) }
|
|
46
|
+
const prepared = prepareReportForEmbed(readFileSync(join(runDir, 'report.html'), 'utf8'), {})
|
|
47
|
+
const SECTIONS = prepared.sections.map((s) => s.id)
|
|
48
|
+
if (SECTIONS.length < 4) { console.error(`the report announces ${SECTIONS.length} sections; this check needs a long one`); process.exit(2) }
|
|
49
|
+
|
|
50
|
+
// ── TWELVE READS OF ONE MARK ────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
const KEY = 'acme'
|
|
52
|
+
const MARK = 'VENQORI'
|
|
53
|
+
const PRODUCTS = [['full-country-search', 'Full country search'], ['knockout-search', 'Knockout search'],
|
|
54
|
+
['multi-country-focus-search', 'Multi-country focus search'], ['global-preliminary-search', 'Global preliminary search']]
|
|
55
|
+
const RUNS = Array.from({ length: 12 }, (_, i) => {
|
|
56
|
+
const [product, productName] = PRODUCTS[i % PRODUCTS.length]
|
|
57
|
+
const day = String(18 - i).padStart(2, '0')
|
|
58
|
+
return {
|
|
59
|
+
account: KEY, kind: 'clearance', state: 'delivered', band: 'Moderate', tone: 'medium', bands: [], marks: [],
|
|
60
|
+
reportSchema: 2, held: false, report: `/portal/report/tmpa-read-${i + 1}/`, step: null, stepN: null, stepTotal: null,
|
|
61
|
+
reason: null, failedStage: null, pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
|
|
62
|
+
projectKey: null, projectName: null, markName: MARK, title: MARK, date: `2026-09-${day}`,
|
|
63
|
+
issuedAt: `2026-09-${day}T12:00:00.000Z`, product, productName, runId: `tmpa-read-${i + 1}`,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
const RUN_ID = RUNS[0].runId
|
|
67
|
+
|
|
68
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.png': 'image/png', '.woff2': 'font/woff2' }
|
|
69
|
+
const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
|
|
70
|
+
const file = (res, full) => {
|
|
71
|
+
if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
|
|
72
|
+
res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
|
|
73
|
+
res.end(readFileSync(full))
|
|
74
|
+
}
|
|
75
|
+
const server = createServer((req, res) => {
|
|
76
|
+
const p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname)
|
|
77
|
+
if (p === '/portal/api/me') return json(res, { email: 'reader@example-firm.com', permissions: { run: true, manage: false }, access: [{ kind: 'account', account: KEY }], accounts: [KEY], accountNames: { [KEY]: 'Acme' } })
|
|
78
|
+
if (p === '/portal/admin/roster') return json(res, { customers: [{ key: KEY, name: 'Acme' }] })
|
|
79
|
+
if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
|
|
80
|
+
if (p === '/portal/api/runs') return json(res, { runs: RUNS })
|
|
81
|
+
if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
|
|
82
|
+
if (p === '/portal/api/mcp-access') return json(res, { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null, offers: [] })
|
|
83
|
+
if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
|
|
84
|
+
// Every read serves the one real document; its own resources resolve against the run directory and the
|
|
85
|
+
// pool, as they do from a real pool.
|
|
86
|
+
const inReport = /^\/portal\/report\/[^/]+\/(.*)$/.exec(p)
|
|
87
|
+
if (inReport) {
|
|
88
|
+
if (!inReport[1]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(prepared.html) }
|
|
89
|
+
return file(res, join(runDir, normalize(inReport[1]).replace(/^(\.\.[/\\])+/, '')))
|
|
90
|
+
}
|
|
91
|
+
if (p.startsWith('/portal/report/')) return file(res, join(pool, normalize(p.slice('/portal/report/'.length)).replace(/^(\.\.[/\\])+/, '')))
|
|
92
|
+
const base = p.split('?')[0]
|
|
93
|
+
return file(res, join(DIST, base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')))
|
|
94
|
+
})
|
|
95
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
96
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
97
|
+
|
|
98
|
+
// ── THE BROWSER ────────────────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
const { profile, env: chromeEnv, keep: keepRoot } = browserRun('report-sections-check-')
|
|
100
|
+
if (keep) keepRoot()
|
|
101
|
+
const chrome = spawn('google-chrome', [
|
|
102
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
103
|
+
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
104
|
+
`--user-data-dir=${profile}`, '--window-size=1440,900', '--remote-debugging-port=0', 'about:blank',
|
|
105
|
+
], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
|
|
106
|
+
reapOnExit(chrome)
|
|
107
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
108
|
+
let devtools = ''
|
|
109
|
+
const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || '(nothing)'}`)), 60000)
|
|
110
|
+
chrome.stderr.on('data', (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]) } })
|
|
111
|
+
})
|
|
112
|
+
const ws = new WebSocket(wsUrl)
|
|
113
|
+
let id = 0
|
|
114
|
+
const pending = new Map()
|
|
115
|
+
// The report's frame is sandboxed to an opaque origin, so Chrome runs it as a process of its own and it
|
|
116
|
+
// is not in the page's frame tree. It is attached to as its own target; the newest one is the document on
|
|
117
|
+
// screen.
|
|
118
|
+
let frameSession = null
|
|
119
|
+
ws.addEventListener('message', (e) => {
|
|
120
|
+
const m = JSON.parse(e.data)
|
|
121
|
+
if (m.method === 'Target.attachedToTarget' && m.params?.targetInfo?.type === 'iframe') frameSession = m.params.sessionId
|
|
122
|
+
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
|
|
123
|
+
})
|
|
124
|
+
await new Promise((r) => ws.addEventListener('open', r))
|
|
125
|
+
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
126
|
+
const { result: targets } = await send('Target.getTargets')
|
|
127
|
+
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
128
|
+
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
129
|
+
const sessionId = sess.sessionId
|
|
130
|
+
const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
131
|
+
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
132
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
133
|
+
const until = async (expr, ms = 15000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await value(expr)) return true; await wait(100) } return false }
|
|
134
|
+
await cmd('Page.enable')
|
|
135
|
+
await cmd('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true })
|
|
136
|
+
|
|
137
|
+
// Where each section starts, read inside the frame's own document — not the list it posted to the portal.
|
|
138
|
+
const anchorsInFrame = async () => {
|
|
139
|
+
if (!frameSession) return null
|
|
140
|
+
const answer = await new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId: frameSession, method: 'Runtime.evaluate',
|
|
141
|
+
params: { expression: `${JSON.stringify(SECTIONS)}.map((id) => { const el = document.getElementById(id); return el ? Math.round(el.getBoundingClientRect().top + window.scrollY) : null })`, returnByValue: true } })) })
|
|
142
|
+
return answer.result?.result?.value ?? null
|
|
143
|
+
}
|
|
144
|
+
const strip = () => value(`[...document.querySelectorAll('nav.report-sections .report-section')].map((b) => (b.dataset.reached === 'true' ? 'y' : 'x') + (b.getAttribute('aria-current') === 'true' ? '*' : '')).join(' ')`)
|
|
145
|
+
const filled = (s) => (s ?? '').split(' ').filter((e) => e.startsWith('y')).length
|
|
146
|
+
const current = (s) => (s ?? '').split(' ').findIndex((e) => e.endsWith('*'))
|
|
147
|
+
// Put a document offset at the reading line — the pinned header's bottom, plus the margin a jump lands at.
|
|
148
|
+
const scrollToDocY = async (docY) => {
|
|
149
|
+
for (let i = 0; i < 6; i++) {
|
|
150
|
+
const delta = await value(`(() => { const f = document.querySelector('iframe'); const h = document.querySelector('.report-head');
|
|
151
|
+
const d = f.getBoundingClientRect().top + ${docY} - (h.getBoundingClientRect().bottom + 10);
|
|
152
|
+
window.scrollTo(0, window.scrollY + d); return d })()`)
|
|
153
|
+
await wait(250)
|
|
154
|
+
if (Math.abs(delta ?? 0) < 1) break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const atFoot = () => value(`window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2`)
|
|
158
|
+
const atTop = () => value(`window.scrollY <= 0`)
|
|
159
|
+
const shot = async (name) => {
|
|
160
|
+
if (!shotsDir) return
|
|
161
|
+
await wait(250)
|
|
162
|
+
const { result } = await cmd('Page.captureScreenshot', { format: 'png' })
|
|
163
|
+
writeFileSync(join(shotsDir, name), Buffer.from(result.data, 'base64'))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const fail = []
|
|
167
|
+
const ok = []
|
|
168
|
+
for (const width of [1440, 400]) {
|
|
169
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 })
|
|
170
|
+
for (const theme of ['light', 'dark']) {
|
|
171
|
+
const at = `${width} ${theme}`
|
|
172
|
+
await cmd('Page.navigate', { url: `${origin}/portal/` })
|
|
173
|
+
await value(`(() => { try { localStorage.setItem('cordillera-theme', '${theme}') } catch (e) {} return 1 })()`)
|
|
174
|
+
await cmd('Page.navigate', { url: `${origin}/portal/result/${RUN_ID}` })
|
|
175
|
+
if (!(await until(`document.querySelectorAll('nav.report-sections .report-section').length === ${SECTIONS.length} && document.querySelectorAll('.report-nav .pill').length === ${RUNS.length}`))) {
|
|
176
|
+
fail.push(`${at}: the header never showed ${RUNS.length} reads and ${SECTIONS.length} sections`)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
await wait(800)
|
|
180
|
+
|
|
181
|
+
// THE PLACEMENT: the strip's row is under the Reads row and starts where it starts.
|
|
182
|
+
const box = await value(`(() => { const r = document.querySelector('.report-nav > div').getBoundingClientRect(); const s = document.querySelector('nav.report-sections').getBoundingClientRect();
|
|
183
|
+
return { readsBottom: r.bottom, readsLeft: r.left, stripTop: s.top, stripLeft: s.left, stripRight: s.right, navRight: document.querySelector('.report-nav').getBoundingClientRect().right } })()`)
|
|
184
|
+
if (!box) fail.push(`${at}: the Reads row or the strip is missing`)
|
|
185
|
+
else if (box.stripTop < box.readsBottom - 1) fail.push(`${at}: the strip (top ${box.stripTop}) is beside the Reads (bottom ${box.readsBottom}), not under them`)
|
|
186
|
+
else if (Math.abs(box.stripLeft - box.readsLeft) > 1) fail.push(`${at}: the strip starts at ${box.stripLeft}, not where the Reads start (${box.readsLeft})`)
|
|
187
|
+
else if (Math.abs(box.stripRight - box.navRight) > 1) fail.push(`${at}: the strip's row is not the header's full width`)
|
|
188
|
+
else ok.push(`${at}: the strip is its own full-width row under the ${RUNS.length} reads`)
|
|
189
|
+
await value('window.scrollTo(0, 0)')
|
|
190
|
+
await wait(300)
|
|
191
|
+
await shot(`sections-${width}-${theme}-top.png`)
|
|
192
|
+
|
|
193
|
+
// THE PROGRESS, down and back up. Nothing at the top; one more filled at each section's start.
|
|
194
|
+
const anchors = await anchorsInFrame()
|
|
195
|
+
if (!anchors || anchors.some((a) => a === null)) { fail.push(`${at}: could not read where the sections start inside the frame`); continue }
|
|
196
|
+
const seen = []
|
|
197
|
+
const expectAt = async (label, want, wantCurrent) => {
|
|
198
|
+
const s = await strip()
|
|
199
|
+
seen.push(`${label}=${s}`)
|
|
200
|
+
if (filled(s) !== want || (wantCurrent !== undefined && current(s) !== wantCurrent)) fail.push(`${at}: at ${label} the strip reads "${s}", expected ${want} filled`)
|
|
201
|
+
}
|
|
202
|
+
await expectAt('top', 0, -1)
|
|
203
|
+
// One step into the document, the first section is reached.
|
|
204
|
+
await value('window.scrollTo(0, 40)')
|
|
205
|
+
await wait(300)
|
|
206
|
+
await expectAt('40px down', 1, 0)
|
|
207
|
+
const order = [...SECTIONS.keys(), ...[...SECTIONS.keys()].reverse().slice(1)]
|
|
208
|
+
for (const i of order) {
|
|
209
|
+
await scrollToDocY(anchors[i])
|
|
210
|
+
// The first section starts under the header, so reaching its start is the top of the page, where
|
|
211
|
+
// the owner's rule is that nothing is marked yet.
|
|
212
|
+
const [top, foot] = [await atTop(), await atFoot()]
|
|
213
|
+
await expectAt(SECTIONS[i], top ? 0 : foot ? SECTIONS.length : i + 1, top ? -1 : foot ? SECTIONS.length - 1 : i)
|
|
214
|
+
if (i === 1 && width === 1440) await shot(`sections-${width}-${theme}-two-reached.png`)
|
|
215
|
+
}
|
|
216
|
+
// Between two starts, the count is the one behind.
|
|
217
|
+
await scrollToDocY(Math.round((anchors[1] + anchors[2]) / 2))
|
|
218
|
+
await expectAt(`between ${SECTIONS[1]} and ${SECTIONS[2]}`, 2, 1)
|
|
219
|
+
await value('window.scrollTo(0, 0)')
|
|
220
|
+
await wait(300)
|
|
221
|
+
await expectAt('top again', 0, -1)
|
|
222
|
+
if (!fail.some((f) => f.startsWith(at))) ok.push(`${at}: progress followed the scroll both ways — ${seen.join(', ')}`)
|
|
223
|
+
|
|
224
|
+
// A CLICK STILL JUMPS, and the section jumped to counts as reached.
|
|
225
|
+
const target = 2
|
|
226
|
+
const before = await value('window.scrollY')
|
|
227
|
+
await value(`document.querySelectorAll('nav.report-sections .report-section')[${target}].click()`)
|
|
228
|
+
let settled = before, last = -1
|
|
229
|
+
for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await value('window.scrollY') }
|
|
230
|
+
const s = await strip()
|
|
231
|
+
const foot = await atFoot()
|
|
232
|
+
if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page`)
|
|
233
|
+
else if (filled(s) !== (foot ? SECTIONS.length : target + 1)) fail.push(`${at}: after the jump to "${SECTIONS[target]}" the strip reads "${s}"`)
|
|
234
|
+
else ok.push(`${at}: a press jumps to "${SECTIONS[target]}" and marks it — ${s}`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const line of ok) console.log(` ok ${line}`)
|
|
239
|
+
for (const line of fail) console.log(` FAIL ${line}`)
|
|
240
|
+
try { ws.close() } catch { /* going anyway */ }
|
|
241
|
+
try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
242
|
+
server.close()
|
|
243
|
+
if (!keep) rmSync(pool, { recursive: true, force: true })
|
|
244
|
+
console.log(fail.length ? `\nreport-sections-render-check: ${fail.length} measurement(s) failed.` : `\nreport-sections-render-check: the strip sits under the reads and follows the reader, both ways, at both widths and both themes.`)
|
|
245
|
+
process.exit(fail.length ? 1 : 0)
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// switch is pressed, never an attribute set by hand, and three things must hold on each press: the
|
|
17
17
|
// document took the theme, its background went the right way, and the load id did not change — the page
|
|
18
18
|
// repainted in place rather than reloading.
|
|
19
|
+
//
|
|
20
|
+
// AND ONE SECTION MENU, AT BOTH THEMES. The portal draws the report's section menu in its own header and
|
|
21
|
+
// the frame must carry none. The document served here is shaped as a report rendered before 2026-09-18,
|
|
22
|
+
// with the menu beside the report's header rather than inside it. That is most of the archive, and the
|
|
23
|
+
// shape that left a second menu in the frame, its current item red on red in the dark theme. The probe
|
|
24
|
+
// counts the menus inside the frame; the portal's are counted on the page.
|
|
19
25
|
import { spawn } from 'node:child_process'
|
|
20
26
|
import { createServer } from 'node:http'
|
|
21
27
|
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
@@ -53,7 +59,7 @@ const FRAMEWORK = { framework_key: 'house-triage', title: 'House triage',
|
|
|
53
59
|
const PROBE = `<script>(function(){
|
|
54
60
|
var id=Math.random().toString(36).slice(2);
|
|
55
61
|
function post(){try{parent.postMessage({themeProbe:1,loadId:id,theme:document.documentElement.getAttribute('data-theme'),
|
|
56
|
-
bg:getComputedStyle(document.body).backgroundColor},'*');}catch(e){}}
|
|
62
|
+
bg:getComputedStyle(document.body).backgroundColor,menus:document.querySelectorAll('nav.strip,[data-sec]').length},'*');}catch(e){}}
|
|
57
63
|
new MutationObserver(post).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});
|
|
58
64
|
post();
|
|
59
65
|
})();</script>`
|
|
@@ -61,7 +67,18 @@ const REPORT = (() => {
|
|
|
61
67
|
const html = renderKnockoutHtml({ marks: [{ name: MARK, rating: 'Low', classes: [9], basis: 'Nothing identical in the field screened.',
|
|
62
68
|
factors: ['No identical name was found.'], counterFactors: [], mitigation: '', assessment: '', findings: [] }],
|
|
63
69
|
batch: { executiveSummary: 'One name screened.' } }, FRAMEWORK, { runId: RUN_ID, overall: 'Low', identity: { identity: 'Knockout search' }, issuedDate: '2026-09-18' })
|
|
64
|
-
|
|
70
|
+
// THE OLD SHAPE: today's menu moved to just after the header's closing tag, where the renderer put it
|
|
71
|
+
// before 2026-09-18. A document with no menu at all would make the count below prove nothing.
|
|
72
|
+
const strip = html.match(/<nav class="[^"]*\bstrip\b[^"]*"[^>]*>[\s\S]*?<\/nav>/)?.[0]
|
|
73
|
+
if (!strip) { console.error('the rendered report has no section menu, so this check could not build the archived shape'); process.exit(2) }
|
|
74
|
+
const without = html.replace(strip, '')
|
|
75
|
+
const head = without.indexOf('<div class="rep-stickyhead')
|
|
76
|
+
const tags = /<\/?div\b[^>]*>/g
|
|
77
|
+
tags.lastIndex = head
|
|
78
|
+
let depth = 0, m, close = -1
|
|
79
|
+
while ((m = tags.exec(without))) { depth += m[0].startsWith('</') ? -1 : 1; if (depth === 0) { close = m.index + m[0].length; break } }
|
|
80
|
+
if (close < 0) { console.error('the report header has no closing tag, so this check could not build the archived shape'); process.exit(2) }
|
|
81
|
+
const served = prepareReportForEmbed(without.slice(0, close) + strip + without.slice(close), {}).html
|
|
65
82
|
const at = served.lastIndexOf('</body>')
|
|
66
83
|
return at < 0 ? served + PROBE : served.slice(0, at) + PROBE + served.slice(at)
|
|
67
84
|
})()
|
|
@@ -151,9 +168,19 @@ const press = () => value(`(() => { const b = document.querySelector('button[ari
|
|
|
151
168
|
|
|
152
169
|
const fail = []
|
|
153
170
|
const ok = []
|
|
171
|
+
// ONE MENU: none in the frame, one in the portal's header, under whichever theme is showing.
|
|
172
|
+
const menus = async (theme, probe) => {
|
|
173
|
+
const portal = await value(`document.querySelectorAll('nav.report-sections').length`)
|
|
174
|
+
if (!probe || typeof probe.menus !== 'number') fail.push(`${theme}: the frame never reported its section menus`)
|
|
175
|
+
else if (probe.menus !== 0) fail.push(`${theme}: the frame carries ${probe.menus} section-menu element(s) beside the portal's own`)
|
|
176
|
+
else ok.push(`${theme}: no section menu inside the frame`)
|
|
177
|
+
if (portal !== 1) fail.push(`${theme}: the portal draws ${portal} section menu(s) where it should draw one`)
|
|
178
|
+
else ok.push(`${theme}: the portal draws the one section menu`)
|
|
179
|
+
}
|
|
154
180
|
const light = await settle('light')
|
|
155
181
|
if (!light || light.theme !== 'light') fail.push(`the embedded report never took the portal's light theme — last report from the frame: ${JSON.stringify(light)}`)
|
|
156
182
|
else ok.push(`light: the report took the portal's theme, ground ${light.bg} (luminance ${luminance(light.bg)?.toFixed(2)})`)
|
|
183
|
+
await menus('light', light)
|
|
157
184
|
await shot('report-light.png')
|
|
158
185
|
|
|
159
186
|
if (!(await press())) fail.push('the portal\'s theme switch is not on the Result screen')
|
|
@@ -165,6 +192,7 @@ else {
|
|
|
165
192
|
if (light && dark.loadId !== light.loadId) fail.push('the report reloaded to change theme — it must repaint in place')
|
|
166
193
|
else ok.push('dark: repainted in place, the same load of the document')
|
|
167
194
|
}
|
|
195
|
+
await menus('dark', dark)
|
|
168
196
|
await shot('report-dark.png')
|
|
169
197
|
|
|
170
198
|
await press()
|
|
@@ -178,5 +206,5 @@ for (const line of fail) console.log(` FAIL ${line}`)
|
|
|
178
206
|
try { ws.close() } catch { /* going anyway */ }
|
|
179
207
|
try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
180
208
|
server.close()
|
|
181
|
-
console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme
|
|
209
|
+
console.log(fail.length ? `\nreport-theme-render-check: ${fail.length} measurement(s) failed.` : `\nreport-theme-render-check: the embedded report follows the portal's theme in place, under the portal's one section menu.`)
|
|
182
210
|
process.exit(fail.length ? 1 : 0)
|
|
@@ -148,6 +148,10 @@ const sessionId = sess.sessionId
|
|
|
148
148
|
const cmd = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
149
149
|
await cmd('Page.enable')
|
|
150
150
|
// A probe that throws says so, rather than returning nothing for the assertions after it to misread.
|
|
151
|
+
// A value pasted into code the page evaluates, as a JavaScript string or object literal. JSON.stringify
|
|
152
|
+
// alone leaves `<`, `>`, `/` and the two line separators as they are; escaped, they read the same once
|
|
153
|
+
// parsed and cannot close or break the code they are pasted into.
|
|
154
|
+
const jsLiteral = (v) => JSON.stringify(v).replace(/[<>\/\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
|
151
155
|
const evalIn = async (expr) => {
|
|
152
156
|
const r = (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result
|
|
153
157
|
if (r?.exceptionDetails) console.error(` ! a probe threw: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`)
|
|
@@ -184,7 +188,7 @@ async function reload(ready, what) {
|
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
async function setTheme(theme) {
|
|
187
|
-
await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${
|
|
191
|
+
await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${jsLiteral(theme)}); try { localStorage.setItem('cordillera-theme', ${jsLiteral(theme)}) } catch {} return true })()`)
|
|
188
192
|
await sleep(250)
|
|
189
193
|
}
|
|
190
194
|
|
|
@@ -381,7 +385,10 @@ const SPEC = {
|
|
|
381
385
|
caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
|
|
382
386
|
adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
|
|
383
387
|
blurLine: 'On the top bar. It covers every mark and company on screen, and an open report whole. It stays as you left it on this computer.',
|
|
384
|
-
|
|
388
|
+
// The sign-in page's wording is the owner's, approved 2026-09-19 and used verbatim.
|
|
389
|
+
identity: (email) => `This Clearotron has one user: ${email}. Enter its passphrase.`,
|
|
390
|
+
reset: 'The passphrase was printed once when this Clearotron first started. Lost it? Run clearotron passphrase --reset on the machine running this portal. It prints a new one, once, for the same user.',
|
|
391
|
+
key: 'A key from clearotron key issue is for an AI assistant, not for this page.',
|
|
385
392
|
addPeople: 'To add people, put it behind a login system such as your company single sign-on. How to set that up',
|
|
386
393
|
}
|
|
387
394
|
|
|
@@ -543,18 +550,18 @@ for (const theme of ['light', 'dark']) {
|
|
|
543
550
|
await setTheme(theme)
|
|
544
551
|
const closed = (await evalIn(signInProbe)) ?? {}
|
|
545
552
|
out[`${label}-closed-${theme}`] = closed
|
|
546
|
-
ok(closed.heading === 'Sign in' && closed.identity ===
|
|
547
|
-
`the card keeps its heading,
|
|
548
|
-
ok(closed.lead
|
|
553
|
+
ok(closed.heading === 'Sign in' && closed.identity === SPEC.identity(EMAIL) && closed.field && closed.button === 'Sign in',
|
|
554
|
+
`the card keeps its heading, names the install's one user, and keeps the field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
|
|
555
|
+
ok(!closed.lead && !closed.leadBold, `the "signs in one person" line is gone from under the button (saw ${JSON.stringify(closed.lead)})`)
|
|
549
556
|
ok(closed.summary === 'Administrator help' && closed.open === false && closed.hintsShown === 0,
|
|
550
557
|
`the administrator's lines sit in a closed "Administrator help" fold (saw ${JSON.stringify({ summary: closed.summary, open: closed.open, shown: closed.hintsShown })})`)
|
|
551
558
|
if (!reset) await capture(`sign-in-help-closed-${theme}`)
|
|
552
559
|
await press('.card details.fold > summary')
|
|
553
560
|
const opened = (await evalIn(signInProbe)) ?? {}
|
|
554
561
|
out[`${label}-open-${theme}`] = opened
|
|
555
|
-
ok(opened.open === true && opened.hintsShown ===
|
|
556
|
-
ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.addPeople,
|
|
557
|
-
`the fold holds the
|
|
562
|
+
ok(opened.open === true && opened.hintsShown === 3, `pressing it opens its three lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
|
|
563
|
+
ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.key && opened.hints[2] === SPEC.addPeople,
|
|
564
|
+
`the fold holds the owner's three lines word for word (saw ${JSON.stringify(opened.hints)})`)
|
|
558
565
|
ok(opened.setUp?.text === 'How to set that up' && opened.setUp?.href === LOGIN_IN_FRONT_DOC, `with the "How to set that up" link (saw ${JSON.stringify(opened.setUp)})`)
|
|
559
566
|
ok(opened.codeInside && !opened.overflowsPage && opened.cardWidth <= 420,
|
|
560
567
|
`the reset command stays inside the card at its own width (card ${opened.cardWidth}px, ${opened.codeLines} line(s), inside: ${opened.codeInside})`)
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
4
|
//
|
|
5
|
-
// Removes the internal citation OPENER from comments and prose in the tree
|
|
5
|
+
// Removes the internal citation OPENER from comments and prose in the tree.
|
|
6
6
|
//
|
|
7
7
|
// The form is `tracker issue NNN — ` standing at the head of a sentence, where the citation is not part
|
|
8
8
|
// of what the sentence says but a label in front of it. Stripping the opener leaves the sentence intact:
|
|
9
9
|
//
|
|
10
|
-
// // tracker issue
|
|
10
|
+
// // tracker issue NNNN — the walk must start at the repository root
|
|
11
11
|
// // the walk must start at the repository root
|
|
12
12
|
//
|
|
13
|
-
// assert.ok(x, "Refs tracker issue
|
|
13
|
+
// assert.ok(x, "Refs tracker issue NNNN — an absent file is a finding")
|
|
14
14
|
// assert.ok(x, "an absent file is a finding")
|
|
15
15
|
//
|
|
16
16
|
// WHY THE PATTERN LOOKS OVER-SPECIFIED. Three parts of it are load-bearing and each was measured, not
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// and the replacement is `$1`, which puts that opener back. Without the group the sweep deletes the
|
|
21
21
|
// opening quote of every test name it touches, and a broken string literal is a syntax error in the
|
|
22
22
|
// lucky cases and a changed assertion in the unlucky ones.
|
|
23
|
-
// · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue
|
|
23
|
+
// · `\s+` AFTER THE SEPARATOR, never `\s*`. `tracker issue NNNN-12` is an ITEM suffix, not a citation
|
|
24
24
|
// followed by prose: there is no space after its hyphen. `\s*` eats the item number.
|
|
25
25
|
// · THE `i` FLAG. `Refs tracker issue NNN` is capitalised at the head of a commit-style line and is a
|
|
26
26
|
// fifth of the corpus.
|
package/scripts/test-run.mjs
CHANGED
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
// about paths it did not create is how a tidy-up becomes an outage.
|
|
47
47
|
|
|
48
48
|
import { spawn } from "node:child_process";
|
|
49
|
-
import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync } from "node:fs";
|
|
49
|
+
import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync, symlinkSync, copyFileSync, chmodSync, writeFileSync, realpathSync } from "node:fs";
|
|
50
50
|
import { delimiter, dirname, join, parse as parsePath, resolve, sep } from "node:path";
|
|
51
51
|
import { fileURLToPath } from "node:url";
|
|
52
|
-
import { tmpdir } from "node:os";
|
|
53
|
-
import { snapshotRepo, repoWrites, explainRepoWrites } from "./repo-writes.mjs";
|
|
52
|
+
import { tmpdir, homedir } from "node:os";
|
|
53
|
+
import { snapshotRepo, repoWrites, explainRepoWrites, snapshotHome, explainHomeWrites } from "./repo-writes.mjs";
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
// ── TAIL — THIS WRAPPER READS BOTH SPELLINGS; IT DOES NOT TRANSLATE THE ENVIRONMENT ───────────
|
|
@@ -271,8 +271,8 @@ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
|
|
|
271
271
|
// driver/test/*.test.mjs. They are indistinguishable from code defects. An agent who runs the suite on a
|
|
272
272
|
// branch, sees 295 red, and diffs the failing NAMES against a baseline taken the same way sees zero
|
|
273
273
|
// regressions and calls the branch clean — and it is, but roughly 190 tests never executed, and a real
|
|
274
|
-
// regression inside any of them is invisible by exactly that arithmetic. It has already happened:
|
|
275
|
-
//
|
|
274
|
+
// regression inside any of them is invisible by exactly that arithmetic. It has already happened: a
|
|
275
|
+
// full-suite comparison was taken against a 295-fail baseline.
|
|
276
276
|
//
|
|
277
277
|
// So: refuse, name what is missing, and name the command. REFUSE RATHER THAN INSTALL — this wrapper is
|
|
278
278
|
// what CI and scripts/publication-scan.mjs run the suite through, and a wrapper that can start a network
|
|
@@ -699,7 +699,88 @@ mkdirSync(process.env.CLEAROTRON_SUITE_TELEMETRY_DIR, { recursive: true });
|
|
|
699
699
|
// Taken HERE, at the last statement before the child exists, so nothing this runner does to the tree
|
|
700
700
|
// between the two reads can be mistaken for something a test did. The comparison is in `close`, below.
|
|
701
701
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
702
|
+
|
|
703
|
+
// ── AND NO PRODUCT COMMAND A TEST STARTS REBUILDS THIS CHECKOUT'S PORTAL BUNDLE ─────────────────────
|
|
704
|
+
//
|
|
705
|
+
// `start` rebuilds `portal-ui/dist` when it is older than `portal-ui/src` — right for a reader whose pull
|
|
706
|
+
// left the bundle behind, and a write into this checkout when a test starts `start` from it. Eight driver
|
|
707
|
+
// files do. On a clone where somebody once built the portal and then pulled, the first of them to run
|
|
708
|
+
// rebuilt the bundle mid-suite and the guard above failed the run; the next run found the bundle fresh and
|
|
709
|
+
// passed (measured 2026-09-18). A fresh clone never sees it: with no bundle there is nothing to rebuild.
|
|
710
|
+
//
|
|
711
|
+
// THE SAME SEAT AS THE ENGINE SHIMS, AND NO PRODUCT CHANGE. `start` runs the bare word `npm`, so a shim at
|
|
712
|
+
// the front of PATH answers it for every child that keeps this PATH. It refuses exactly one command —
|
|
713
|
+
// `npm run build:ui` with this checkout as its working directory — and hands everything else to the npm
|
|
714
|
+
// it found on PATH. `start` reports a refused rebuild and carries on serving, which is its behaviour on
|
|
715
|
+
// any failed build. A child that composes its own PATH walks past this, and the guard above still
|
|
716
|
+
// catches what it writes.
|
|
717
|
+
function onPath(name, pathValue) {
|
|
718
|
+
for (const d of String(pathValue ?? "").split(delimiter).filter(Boolean)) {
|
|
719
|
+
const p = join(d, name);
|
|
720
|
+
try { if (statSync(p).isFile()) return p; } catch { /* not here */ }
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
// COUNTED, because the refusal itself is printed into a child that a test usually captures: the run
|
|
725
|
+
// reports how many rebuilds it turned away, so a stale bundle is named once where a reader can see it.
|
|
726
|
+
let npmShimDir = null;
|
|
727
|
+
if (process.platform !== "win32") {
|
|
728
|
+
const realNpm = onPath("npm", process.env.PATH);
|
|
729
|
+
if (realNpm) {
|
|
730
|
+
const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
|
|
731
|
+
npmShimDir = join(root, "npm-shim");
|
|
732
|
+
mkdirSync(npmShimDir, { recursive: true });
|
|
733
|
+
writeFileSync(join(npmShimDir, "npm"), [
|
|
734
|
+
"#!/bin/sh",
|
|
735
|
+
"# Written by scripts/test-run.mjs for one suite run; removed with the run's temp root.",
|
|
736
|
+
`if [ "$1" = run ] && [ "$2" = build:ui ] && [ "$(pwd -P)" = ${q(realpathSync(REPO_ROOT))} ]; then`,
|
|
737
|
+
" echo '[test-run] refused: npm run build:ui in the checkout. A suite run does not rebuild its portal bundle.' >&2",
|
|
738
|
+
` echo refused >> ${q(join(npmShimDir, "refused"))}`,
|
|
739
|
+
" exit 1",
|
|
740
|
+
"fi",
|
|
741
|
+
`exec ${q(realNpm)} "$@"`,
|
|
742
|
+
"",
|
|
743
|
+
].join("\n"), { mode: 0o755 });
|
|
744
|
+
process.env.PATH = npmShimDir + delimiter + process.env.PATH;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
702
747
|
const repoBefore = snapshotRepo(REPO_ROOT);
|
|
748
|
+
// AND THE HOME IT RUNS AS, read at the same moment for the same reason (`repo-writes.mjs` says which
|
|
749
|
+
// folders and why). HOME as the child inherits it, which is the home every unpinned product command in
|
|
750
|
+
// the run resolves.
|
|
751
|
+
const RUN_HOME = String(process.env.HOME ?? "").trim() || homedir();
|
|
752
|
+
const homeBefore = snapshotHome(RUN_HOME);
|
|
753
|
+
|
|
754
|
+
// ── AND NOTHING THE RUN MADE IS LEFT IN THE MACHINE'S TEMP DIRECTORY ────────────────────────────────
|
|
755
|
+
//
|
|
756
|
+
// The run's own root is removed on every exit. What escapes it is a child handed an environment without
|
|
757
|
+
// TMPDIR: it falls back to the machine's temp directory, and nothing removes what it made there. Measured
|
|
758
|
+
// 2026-09-19: fifteen `clearotron-demo-*` directories per `npm test`, from the demo's temporary sample
|
|
759
|
+
// copies, and 36 GB accumulated on one box. Named by the product prefix, so the guard reads only what
|
|
760
|
+
// this product makes; owned by this account, so another user's run is not ours to count.
|
|
761
|
+
//
|
|
762
|
+
// THE OUTERMOST RUN WATCHES THE MACHINE'S TEMP ROOTS. A nested run watches only its own base, and only
|
|
763
|
+
// when a test arms it (CT_TEMP_LEAK_GUARD=1): the runner's own tests drive nested runs while the rest of
|
|
764
|
+
// the suite is working, and a nested guard reading the shared root would count its neighbours' files.
|
|
765
|
+
const LEAK_PREFIXES = Object.freeze(["clearotron-demo-"]);
|
|
766
|
+
const OUTERMOST = !String(process.env.CT_TEST_MACHINE_TMP ?? "").trim();
|
|
767
|
+
const LEAK_ROOTS = OUTERMOST
|
|
768
|
+
? [...new Set([MACHINE_TMP, REAL_TMP, ...PLATFORM_TMP].map((p) => resolve(p)))]
|
|
769
|
+
: String(process.env.CT_TEMP_LEAK_GUARD ?? "") === "1" ? [resolve(REAL_TMP)] : [];
|
|
770
|
+
function tempLeftovers() {
|
|
771
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
772
|
+
const out = new Set();
|
|
773
|
+
for (const r of LEAK_ROOTS) {
|
|
774
|
+
let names;
|
|
775
|
+
try { names = readdirSync(r); } catch { continue; }
|
|
776
|
+
for (const n of names) {
|
|
777
|
+
if (!LEAK_PREFIXES.some((p) => n.startsWith(p))) continue;
|
|
778
|
+
try { if (uid == null || statSync(join(r, n)).uid === uid) out.add(join(r, n)); } catch { /* gone already */ }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
const tempBefore = tempLeftovers();
|
|
703
784
|
|
|
704
785
|
|
|
705
786
|
child = spawn(argv[0], argv.slice(1), {
|
|
@@ -741,7 +822,14 @@ child.on("error", (e) => {
|
|
|
741
822
|
});
|
|
742
823
|
|
|
743
824
|
child.on("close", (code, signal) => {
|
|
825
|
+
let refusedBuilds = 0;
|
|
826
|
+
try { if (npmShimDir) refusedBuilds = readFileSync(join(npmShimDir, "refused"), "utf8").split("\n").filter(Boolean).length; }
|
|
827
|
+
catch { /* none refused */ }
|
|
744
828
|
cleanup();
|
|
829
|
+
if (refusedBuilds) {
|
|
830
|
+
console.error(`[test-run] turned away ${refusedBuilds} rebuild(s) of this checkout's portal bundle: it is older than`);
|
|
831
|
+
console.error(" portal-ui/src. `npm run build:ui` brings it current; until then doctor's arms report it stale.");
|
|
832
|
+
}
|
|
745
833
|
// The exit code IS the result — CI reads it. Never swallow a failure to report a tidy cleanup.
|
|
746
834
|
// A SIGNALLED RUN IS NOT EVIDENCE ABOUT WRITES: it was cancelled mid-flight, so a half-finished
|
|
747
835
|
// fixture proves nothing and re-raising is the honest answer. The guard below never runs on that path.
|
|
@@ -749,8 +837,22 @@ child.on("close", (code, signal) => {
|
|
|
749
837
|
|
|
750
838
|
const wrote = repoWrites(repoBefore, snapshotRepo(REPO_ROOT), REPO_ROOT);
|
|
751
839
|
if (wrote.length) for (const line of explainRepoWrites(wrote)) console.error(line);
|
|
840
|
+
const wroteHome = repoWrites(homeBefore, snapshotHome(RUN_HOME), RUN_HOME);
|
|
841
|
+
if (wroteHome.length) for (const line of explainHomeWrites(wroteHome, RUN_HOME)) console.error(line);
|
|
842
|
+
const leftInTemp = [...tempLeftovers()].filter((p) => !tempBefore.has(p)).sort();
|
|
843
|
+
if (leftInTemp.length) {
|
|
844
|
+
console.error("");
|
|
845
|
+
console.error(`[test-run] THIS RUN LEFT ${leftInTemp.length} DIRECTOR${leftInTemp.length === 1 ? "Y" : "IES"} IN THE MACHINE'S TEMP DIRECTORY:`);
|
|
846
|
+
for (const p of leftInTemp) console.error(` + ${p}`);
|
|
847
|
+
console.error("");
|
|
848
|
+
console.error(" A child handed an environment without TMPDIR puts its temporary files in the machine's temp");
|
|
849
|
+
console.error(" directory, where nothing removes them. Pass `TMPDIR: tmpdir()` in that child's env, so they land");
|
|
850
|
+
console.error(" in this run's own root, which is removed on every exit.");
|
|
851
|
+
console.error("");
|
|
852
|
+
console.error(" (Another run on this account making the same directories at the same time prints this too.)");
|
|
853
|
+
}
|
|
752
854
|
// THIS MAY TURN A GREEN RUN RED. IT MUST NEVER TURN A RED RUN GREEN — a failing suite keeps its own
|
|
753
855
|
// exit code, because what the tests found matters more than what they wrote while finding it.
|
|
754
856
|
const childCode = code ?? 1;
|
|
755
|
-
process.exit(childCode !== 0 ? childCode : (wrote.length ? 1 : 0));
|
|
857
|
+
process.exit(childCode !== 0 ? childCode : (wrote.length || wroteHome.length || leftInTemp.length ? 1 : 0));
|
|
756
858
|
});
|