clearotron 0.2.2 → 0.2.4
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 +64 -37
- package/.nvmrc +1 -1
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +36 -14
- package/README.md +18 -22
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +10 -5
- package/bin/onboard.mjs +370 -28
- package/bin/start.mjs +220 -12
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/GLOSSARY.md +85 -0
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +6 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +19 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +90 -0
- package/driver/README.md +25 -6
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/dev-portal.mjs +2 -2
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/engine/probe.mjs +45 -5
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +20 -3
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +211 -106
- package/driver/plain-register.mjs +134 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +70 -2
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +79 -9
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +151 -2
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +111 -16
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +3 -3
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +54 -7
- package/driver/suite-census.json +220 -58
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +214 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +31 -19
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/e2e.mjs +2 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/generated-files-are-current.mjs +125 -0
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/mint-names-in-force.mjs +4 -2
- package/scripts/mint-public-residue.mjs +74 -0
- package/scripts/mint-reference-strip-backlog.mjs +12 -1
- package/scripts/mint-suite-census.mjs +5 -2
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/render-check.mjs +13 -2
- package/scripts/report-frame-check.mjs +8 -1
- package/scripts/report-screenshot.mjs +6 -3
- package/scripts/revisit-render-check.mjs +6 -3
- package/scripts/score.mjs +1 -1
- package/scripts/strip-tracker-citations.mjs +38 -7
- package/scripts/test-full.mjs +226 -0
- package/scripts/test-run.mjs +16 -0
- package/shared/README.md +2 -1
- package/shared/anon-overlay.mjs +3 -3
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +33 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +6 -1
- package/shared/node-floor.mjs +97 -0
- package/shared/os-advice.mjs +91 -0
- package/shared/reap-on-exit.mjs +42 -0
- package/shared/reference-guard-classes.mjs +351 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/shared/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
|
@@ -1,480 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
-
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
-
// Do the columns on /portal/clearances hold together, in a real browser, at a real width?
|
|
5
|
-
//
|
|
6
|
-
// node scripts/clearances-render-check.mjs [--keep] [--shot <path>] [--width 1100]
|
|
7
|
-
//
|
|
8
|
-
// ── why this exists ──────────────────────────────────────────────────────────────────────────────────
|
|
9
|
-
//
|
|
10
|
-
// Because nothing else in this repo can answer the question. portal-ui runs `node --test` with type
|
|
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' // Page.navigate returns an errorText, and nothing read it
|
|
13
|
-
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
14
|
-
// four source-text tests over Clearances.tsx can prove a string is in a file and nothing more. They
|
|
15
|
-
// cannot see a width, an alignment, or a scrollbar, which is precisely what and are about.
|
|
16
|
-
//
|
|
17
|
-
// So this serves the REAL built bundle to a REAL browser and MEASURES: getBoundingClientRect on the
|
|
18
|
-
// cells, scrollWidth against clientWidth, and the rendered line count of a date. A screenshot proves a
|
|
19
|
-
// layout at one width on one day; these numbers hold every run.
|
|
20
|
-
//
|
|
21
|
-
// Sibling of portal-lifecycle-check.mjs and composer-render-check.mjs, deliberately separate from both:
|
|
22
|
-
// that one is about what the page SAYS (owner names, lifecycles) and this one is about where the page
|
|
23
|
-
// PUTS it. A check that grew both jobs would be re-run in full for either.
|
|
24
|
-
//
|
|
25
|
-
// MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one. Run
|
|
26
|
-
// it as a user with `ulimit -v unlimited`.
|
|
27
|
-
|
|
28
|
-
import { createServer } from 'node:http'
|
|
29
|
-
import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
30
|
-
import { join, extname, dirname } from 'node:path'
|
|
31
|
-
import { fileURLToPath } from 'node:url'
|
|
32
|
-
import { spawn } from 'node:child_process'
|
|
33
|
-
import { tmpdir } from 'node:os'
|
|
34
|
-
|
|
35
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
36
|
-
const DIST = join(HERE, '..', 'portal-ui', 'dist')
|
|
37
|
-
const keep = process.argv.includes('--keep')
|
|
38
|
-
const shotAt = process.argv.includes('--shot') ? process.argv[process.argv.indexOf('--shot') + 1] : null
|
|
39
|
-
const WIDTH = process.argv.includes('--width') ? Number(process.argv[process.argv.indexOf('--width') + 1]) : 1100
|
|
40
|
-
|
|
41
|
-
if (!existsSync(join(DIST, 'index.html'))) {
|
|
42
|
-
console.error(`no build at ${DIST} — run: npm run build:ui`)
|
|
43
|
-
process.exit(2)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ── the fixture ─────────────────────────────────────────────────────────────────────────────────────
|
|
47
|
-
//
|
|
48
|
-
// Shaped from the four rows the E2E suite actually seeds (measured on the test instance 2026-08-04), so
|
|
49
|
-
// this check exercises the cases the review found rather than a convenient invention:
|
|
50
|
-
//
|
|
51
|
-
// • two reads of one mark, at DIFFERENT depths and with different title lengths — the pair that made
|
|
52
|
-
// the alignment drift visible, because the second read's Risk sat ~120px right of the first's
|
|
53
|
-
// • a not-finished read, whose Status used to be a raw engine string
|
|
54
|
-
// • a batch, whose Name column used to hold a run type
|
|
55
|
-
//
|
|
56
|
-
// The long run title is the instrument for the last assertion: lengthen it and NOTHING else may move.
|
|
57
|
-
|
|
58
|
-
const KEY = 'vantor'
|
|
59
|
-
const NAME = 'Vantor Labs'
|
|
60
|
-
// — a SECOND brand owner, because the toggle only exists when there is more than one and the four
|
|
61
|
-
// rows the E2E suite seeds are one per owner. The issue says the fault is not observable on that data
|
|
62
|
-
// and to say so if arranging otherwise is awkward; here it is not, because this fixture is ours.
|
|
63
|
-
const KEY2 = 'petcary'
|
|
64
|
-
const NAME2 = 'Petcary'
|
|
65
|
-
const LONG_TITLE_MARK = 'A DELIBERATELY VERY LONG MARK NAME FOR THE DRIFT PROBE'
|
|
66
|
-
|
|
67
|
-
const bands = [
|
|
68
|
-
{ label: 'Severe', tone: 'severe' },
|
|
69
|
-
{ label: 'Medium', tone: 'medium' },
|
|
70
|
-
{ label: 'Manageable', tone: 'low' },
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
// THE ROW, AS THE SERVICE SENDS IT. `productName` is resolved here by the SAME expression
|
|
74
|
-
// portal-service.mjs evaluates (reportIdentityFor(product).identity), never typed: the file's own rule
|
|
75
|
-
// is that typing a product name re-introduces the second answer this check exists to catch.
|
|
76
|
-
//
|
|
77
|
-
// `stageLabel` stays on every row, and it stays a DEPTH NUMBER. That is deliberate and it is what gives
|
|
78
|
-
// the assertions below their teeth: the wire really does carry "Depth 4" on a run delivered under the
|
|
79
|
-
// retired ladder, and the screen must print the product's name instead. A fixture that omitted the rung
|
|
80
|
-
// could not tell a screen that ignores it from a screen that never had one offered.
|
|
81
|
-
const { reportIdentityFor } = await import('../driver/search-policy.mjs')
|
|
82
|
-
const run = (over) => {
|
|
83
|
-
const product = over.product ?? 'global-preliminary-search'
|
|
84
|
-
return {
|
|
85
|
-
account: KEY, kind: 'clearance', state: 'delivered', band: 'Manageable', tone: 'low',
|
|
86
|
-
bands, marks: [], reportSchema: 2, held: false, report: 'report.html',
|
|
87
|
-
step: null, stepN: null, stepTotal: null, reason: null, failedStage: null,
|
|
88
|
-
pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
|
|
89
|
-
projectKey: null, projectName: null, markName: null, title: '', date: '2026-08-02',
|
|
90
|
-
product, stageLabel: 'Depth 4', productName: reportIdentityFor(product).identity, runId: '', ...over,
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
let longTitle = false
|
|
95
|
-
|
|
96
|
-
const RUNS = () => [
|
|
97
|
-
// The pair. Two reads of one mark, different depths, and — when `longTitle` is on — a title that grows.
|
|
98
|
-
run({ runId: 'tmpa-drift-1', markName: longTitle ? LONG_TITLE_MARK : 'VENZY', title: 'VENZY', date: '2026-08-02', band: 'Manageable', stageLabel: 'Depth 4', product: 'global-preliminary-search' }),
|
|
99
|
-
run({ runId: 'tmpa-drift-2', markName: longTitle ? LONG_TITLE_MARK : 'VENZY', title: 'VENZY', date: '2026-08-01', band: 'Medium', tone: 'medium', stageLabel: 'Depth 1', product: 'knockout-search' }),
|
|
100
|
-
// Not finished. Its Status is the case is about.
|
|
101
|
-
run({
|
|
102
|
-
runId: 'tmpb-stopped', markName: 'VIBRANTE FROSTPLUM', title: 'VIBRANTE FROSTPLUM', date: '2026-08-02',
|
|
103
|
-
state: 'failed', band: null, tone: null, report: null,
|
|
104
|
-
failedStage: 'common-law-half:b',
|
|
105
|
-
reason: 'invalid_file:prelim-search/tmpe2er1-vibrante-frostplum/2026-08-02-fixture/common-law-findings.half-b.md:connotation_undisposed:VIBRANTE FROSTPLUM urban dictionary,FROSTPLUM meaning slang,FR',
|
|
106
|
-
}),
|
|
107
|
-
// A second brand owner, so the grouping toggle has something to group.
|
|
108
|
-
run({ runId: 'tmpd-other', account: KEY2, markName: 'ASTERION', title: 'ASTERION', date: '2026-08-03', band: 'Severe', tone: 'severe', stageLabel: 'Depth 4', product: 'global-preliminary-search' }),
|
|
109
|
-
// A batch.
|
|
110
|
-
run({
|
|
111
|
-
runId: 'tmpc-batch', markName: null, title: 'Knockout search — 2 marks', kind: 'knockout-batch',
|
|
112
|
-
stageLabel: 'Depth 1', product: 'knockout-search', date: '2026-08-02',
|
|
113
|
-
marks: [{ name: 'E2E DUPLICATE PROBE', band: 'Manageable', tone: 'low' }, { name: 'E2E FALLBACK PROBE', band: 'Medium', tone: 'medium' }],
|
|
114
|
-
}),
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
// ── the /portal/api/searches stub ───────────────────────────────────────────────────────────────────
|
|
118
|
-
//
|
|
119
|
-
// CLEARANCES NO LONGER FETCHES THIS. It used to, for one reason — to turn `run.product` into a
|
|
120
|
-
// name — and that route returns the ORDERABLE products only, so every archived run missed the join and
|
|
121
|
-
// the screen printed the run's own `stageLabel`, a Depth number, at a client. The name arrives on the
|
|
122
|
-
// row now. The route is still stubbed because other screens in the bundle call it; nothing on THIS
|
|
123
|
-
// screen depends on it, and the assertions below prove that by leaving a Depth string on every row.
|
|
124
|
-
//
|
|
125
|
-
// The rows still come from `driver/product-rows.mjs` — the same function the live service calls — so a
|
|
126
|
-
// product renamed in the offering renames here on the same commit.
|
|
127
|
-
const { productRows } = await import('../driver/product-rows.mjs')
|
|
128
|
-
const PRODUCTS = productRows().map((p) => ({ ...p, available: true, unavailableNote: null }))
|
|
129
|
-
// The name the OFFERING gives each product, for comparing against what the screen rendered. Typing these
|
|
130
|
-
// would re-introduce the second answer this file exists to catch.
|
|
131
|
-
const PRODUCT_NAME = Object.fromEntries(PRODUCTS.map((p) => [p.key, p.name]))
|
|
132
|
-
if (!PRODUCTS.length || PRODUCTS.some((p) => !p.key || !p.name)) {
|
|
133
|
-
console.error(`the product registry answered ${PRODUCTS.length} usable rows — there is nothing to check the rendered names against`)
|
|
134
|
-
process.exit(2)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
|
|
138
|
-
const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
|
|
139
|
-
|
|
140
|
-
const server = createServer((req, res) => {
|
|
141
|
-
const p = new URL(req.url, 'http://localhost').pathname
|
|
142
|
-
if (p === '/portal/api/me') return json(res, { email: 'staff@example-firm.com', role: 'staff', accounts: '*', accountNames: {}, allowance: null })
|
|
143
|
-
if (p === '/portal/admin/roster') return json(res, { customers: [{ key: KEY, name: NAME }, { key: KEY2, name: NAME2 }] })
|
|
144
|
-
if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
|
|
145
|
-
if (p === '/portal/api/runs') return json(res, { runs: RUNS() })
|
|
146
|
-
if (p === '/portal/api/searches') return json(res, { account: KEY, products: PRODUCTS, recipes: [], read: { available: false, maxBrief: 0, note: null } })
|
|
147
|
-
if (p === '/portal/api/mcp-access') return json(res, { url: null, keyUrl: null, email: null, enabled: false })
|
|
148
|
-
const base = p.split('?')[0]
|
|
149
|
-
const file = base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')
|
|
150
|
-
const full = join(DIST, file)
|
|
151
|
-
if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
|
|
152
|
-
res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
|
|
153
|
-
res.end(readFileSync(full))
|
|
154
|
-
})
|
|
155
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
156
|
-
const origin = `http://127.0.0.1:${server.address().port}`
|
|
157
|
-
|
|
158
|
-
// ── what is measured, in the page ───────────────────────────────────────────────────────────────────
|
|
159
|
-
//
|
|
160
|
-
// Every number here comes from the layout engine. Nothing is inferred from a class name: a rule that is
|
|
161
|
-
// present but overridden looks identical to a rule that works, and that is exactly the failure a string
|
|
162
|
-
// test cannot tell apart.
|
|
163
|
-
|
|
164
|
-
const MEASURE = `(async () => {
|
|
165
|
-
// Open every row: the alignment question only exists once a read is showing.
|
|
166
|
-
const rows = [...document.querySelectorAll('table.data tr.row')];
|
|
167
|
-
for (const r of rows) { r.click(); await new Promise(r2 => setTimeout(r2, 60)); }
|
|
168
|
-
await new Promise(r => setTimeout(r, 350));
|
|
169
|
-
|
|
170
|
-
const table = document.querySelector('table.data');
|
|
171
|
-
if (!table) return { fatal: 'no table on the page' };
|
|
172
|
-
const wrap = table.closest('.table-wrap');
|
|
173
|
-
const head = [...table.querySelectorAll('thead th')];
|
|
174
|
-
const headText = head.map(h => (h.textContent || '').trim());
|
|
175
|
-
const headLeft = head.map(h => Math.round(h.getBoundingClientRect().left));
|
|
176
|
-
|
|
177
|
-
// Every read row's cells, against the header cell of the same index.
|
|
178
|
-
const reads = [...table.querySelectorAll('tr.read-row')].map(tr => ({
|
|
179
|
-
left: [...tr.children].map(td => Math.round(td.getBoundingClientRect().left)),
|
|
180
|
-
cols: tr.children.length,
|
|
181
|
-
}));
|
|
182
|
-
|
|
183
|
-
// The Updated cell: how many lines does its text actually occupy? lineHeight vs rendered height is
|
|
184
|
-
// the only honest way to ask — a wrapped date and a one-line date are the same string.
|
|
185
|
-
const updatedIdx = headText.findIndex(t => /^UPDATED$/i.test(t));
|
|
186
|
-
const dateCells = [...table.querySelectorAll('tbody tr.row')].map(tr => tr.children[updatedIdx]).filter(Boolean);
|
|
187
|
-
const dateLines = dateCells.map(td => {
|
|
188
|
-
const cs = getComputedStyle(td);
|
|
189
|
-
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4;
|
|
190
|
-
const inner = td.getBoundingClientRect().height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
191
|
-
return Math.max(1, Math.round(inner / lh));
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// Does the NAME wrap? Same method as the date: rendered height against line height. The Name cell is
|
|
195
|
-
// the one that must not wrap for a single mark of ordinary length, and counting characters
|
|
196
|
-
// cannot answer that — only the layout engine can.
|
|
197
|
-
const nameIdx = headText.findIndex(t => /^NAME/i.test(t));
|
|
198
|
-
const nameLines = [...table.querySelectorAll('tbody tr.row')].map(tr => {
|
|
199
|
-
const td = tr.children[nameIdx];
|
|
200
|
-
if (!td) return null;
|
|
201
|
-
const b = td.querySelector('b');
|
|
202
|
-
if (!b) return null;
|
|
203
|
-
const cs = getComputedStyle(b);
|
|
204
|
-
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4;
|
|
205
|
-
return { text: (b.textContent || '').trim(), lines: Math.max(1, Math.round(b.getBoundingClientRect().height / lh)) };
|
|
206
|
-
}).filter(Boolean);
|
|
207
|
-
|
|
208
|
-
// Header widths as a share of the table, so a regression reads as "Status is 47% again".
|
|
209
|
-
const total = table.getBoundingClientRect().width;
|
|
210
|
-
// Keyed by INDEX as well as text: tracker issue 282 left two headers with no text (the twisty and the checkbox
|
|
211
|
-
// column), and keying on text alone silently collapsed them into one entry. Concatenation, not a
|
|
212
|
-
// template literal — this whole block IS a template literal, so a backtick here closes it.
|
|
213
|
-
const share = Object.fromEntries(head.map((h, i) => [i + ':' + (headText[i] || '(unnamed)'), Math.round(h.getBoundingClientRect().width / total * 1000) / 10]));
|
|
214
|
-
|
|
215
|
-
// THE PRODUCT NAME, as rendered. readLabel (contract/reads.ts) reads run.productName off the row and
|
|
216
|
-
// falls back to the run's own stageLabel only when there is no name. Both paths produce a string in the
|
|
217
|
-
// same place, so only the STRING can tell them apart: "Global preliminary search · 2026-08-02" is the
|
|
218
|
-
// name, "Depth 4 · 2026-08-02" is the fallback — and every fixture row below carries that exact Depth
|
|
219
|
-
// string, so a screen that reached for it would say so here. NO BACKTICKS IN THIS COMMENT: the whole
|
|
220
|
-
// block is a template literal and one would close it.
|
|
221
|
-
//
|
|
222
|
-
// Read from the FIRST cell's own span (Clearances.tsx renders the label there); the row's whole
|
|
223
|
-
// textContent would sweep up the status and risk cells and make any assertion about the label
|
|
224
|
-
// satisfiable by something else on the row.
|
|
225
|
-
const readLabels = [...document.querySelectorAll('tr.read-row')]
|
|
226
|
-
.map(tr => tr.querySelector('td span.mono'))
|
|
227
|
-
.filter(Boolean)
|
|
228
|
-
.map(el => (el.firstChild && el.firstChild.textContent ? el.firstChild.textContent : el.textContent || '').trim())
|
|
229
|
-
.filter(Boolean);
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
headText, headLeft, reads, dateLines, nameLines, share, readLabels,
|
|
233
|
-
openedRows: rows.length,
|
|
234
|
-
readRows: reads.length,
|
|
235
|
-
// The scrollbar tracker issue 280 is about: does the table overflow its own wrapper?
|
|
236
|
-
// AGAINST THE BORDER BOX, not clientWidth.
|
|
237
|
-
//
|
|
238
|
-
// clientWidth excludes a rendered scrollbar, so on a classic-scrollbar platform it is already 19px
|
|
239
|
-
// smaller THE MOMENT the bar appears — and the bar appears because the content exceeded it. Comparing
|
|
240
|
-
// the two asks a circular question and answers it "overflowing" by exactly one scrollbar width,
|
|
241
|
-
// whatever the layout does. Measured: CI reported table 788px in a 769px wrapper three times running,
|
|
242
|
-
// with min-width at 760, then 700, then 0 — the number never moved, because it was never the floor.
|
|
243
|
-
//
|
|
244
|
-
// The real question is whether the table is wider than its container, and the container's border box
|
|
245
|
-
// is what a reader sees. This machine has overlay scrollbars and never showed the difference.
|
|
246
|
-
overflowsX: wrap ? table.getBoundingClientRect().width > wrap.getBoundingClientRect().width + 1 : null,
|
|
247
|
-
overflowBy: wrap ? Math.round(table.getBoundingClientRect().width - wrap.getBoundingClientRect().width) : null,
|
|
248
|
-
tableMinWidth: getComputedStyle(table).minWidth,
|
|
249
|
-
tableWidth: Math.round(table.getBoundingClientRect().width),
|
|
250
|
-
docOverflowsX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
251
|
-
tableLayout: getComputedStyle(table).tableLayout,
|
|
252
|
-
headerTruncated: headText.some(t => /^UPD$|^STA$|^RIS$/i.test(t)),
|
|
253
|
-
};
|
|
254
|
-
})()`
|
|
255
|
-
|
|
256
|
-
// ── chrome ──────────────────────────────────────────────────────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
const userDir = mkdtempSync(join(tmpdir(), 'clearances-check-'))
|
|
259
|
-
// NO NETWORK, deliberately. CI runs this with no route to api.fontshare.com, so the brand webfonts never
|
|
260
|
-
// arrive and the page renders in a wider fallback — which is exactly when a cell wraps and a table
|
|
261
|
-
// overflows. A check that passes only when the fonts load is a check that passes on the developer's
|
|
262
|
-
// machine and fails in CI, which is how this was found.
|
|
263
|
-
const chrome = spawn('google-chrome', [
|
|
264
|
-
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
265
|
-
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
266
|
-
`--user-data-dir=${userDir}`, `--window-size=${WIDTH},1000`,
|
|
267
|
-
'--remote-debugging-port=0', `${origin}/portal/clearances`,
|
|
268
|
-
// — DETACHED so Chrome LEADS A PROCESS GROUP. Its renderer, GPU and zygote processes are
|
|
269
|
-
// separate PIDs, and without a group there is nothing to signal them with.
|
|
270
|
-
], { stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
|
271
|
-
// — and the group dies with THIS script, on every exit it can observe.
|
|
272
|
-
// The teardown below runs on the paths somebody wrote a branch for; a cancelled CI job (SIGTERM),
|
|
273
|
-
// a Ctrl-C, or a throw elsewhere in this file are not among them — and that is where the measured
|
|
274
|
-
// eighty-eight-minute orphan came from.
|
|
275
|
-
reapOnExit(chrome);
|
|
276
|
-
|
|
277
|
-
let devtools = ''
|
|
278
|
-
const wsUrl = await new Promise((resolve, reject) => {
|
|
279
|
-
// 60s, not 20s. The 20s budget was set when CI ran on a paid 4-vCPU larger runner; this workflow now
|
|
280
|
-
// runs on the standard 2-vCPU ubuntu-latest, where a cold Chrome start has no margin in 20s — it
|
|
281
|
-
// failed once in the first 6 runs there with 'chrome did not report a devtools endpoint'.
|
|
282
|
-
//
|
|
283
|
-
// WHAT THE NUMBERS DO NOT SHOW, stated because the tempting claim is wrong: this is NOT established
|
|
284
|
-
// as a regression from the runner change. The same step failed 6 times in 34 runs on ubuntu-latest-m
|
|
285
|
-
// — but all 6 landed inside one 16-minute window on 2026-08-10, across several branches at once,
|
|
286
|
-
// which is an infrastructure incident and not a rate. Outside it: 0 in 28. So the evidence is
|
|
287
|
-
// consistent with the smaller runner being tighter and does not demonstrate it; n is small on both
|
|
288
|
-
// sides. The budget goes up because 20s for a cold Chrome start is thin on any 2-vCPU box, not
|
|
289
|
-
// because a measurement proved causation.
|
|
290
|
-
//
|
|
291
|
-
// And on timeout, SAY WHAT CHROME SAID — the bare message named a symptom and no cause, so the one
|
|
292
|
-
// thing the next reader needs was the one thing it withheld.
|
|
293
|
-
const t = setTimeout(
|
|
294
|
-
() => reject(new Error(`chrome did not report a devtools endpoint within 60s. Its stderr was:\n${devtools || '(nothing)'}`)),
|
|
295
|
-
60000,
|
|
296
|
-
)
|
|
297
|
-
chrome.stderr.on('data', (c) => {
|
|
298
|
-
devtools += c
|
|
299
|
-
const m = devtools.match(/ws:\/\/[^\s]+/)
|
|
300
|
-
if (m) { clearTimeout(t); resolve(m[0]) }
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
const ws = new WebSocket(wsUrl)
|
|
305
|
-
let id = 0
|
|
306
|
-
const pending = new Map()
|
|
307
|
-
ws.addEventListener('message', (e) => {
|
|
308
|
-
const msg = JSON.parse(e.data)
|
|
309
|
-
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
|
|
310
|
-
})
|
|
311
|
-
await new Promise((r) => ws.addEventListener('open', r))
|
|
312
|
-
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
313
|
-
|
|
314
|
-
const { result: targets } = await send('Target.getTargets')
|
|
315
|
-
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
316
|
-
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
317
|
-
const sessionId = sess.sessionId
|
|
318
|
-
const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
319
|
-
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
320
|
-
|
|
321
|
-
const reload = async () => {
|
|
322
|
-
await navigateOrRefuse(cmd, `${origin}/portal/clearances`, { what: 'clearances-render-check' })
|
|
323
|
-
await new Promise((r) => setTimeout(r, 1800))
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
await new Promise((r) => setTimeout(r, 1500))
|
|
327
|
-
await reload()
|
|
328
|
-
const short = await value(MEASURE)
|
|
329
|
-
|
|
330
|
-
// — the grouping toggle, both ways. Grouped, the owner is a section header; ungrouped, it is a
|
|
331
|
-
// column on every row. The information has to survive the toggle, and the sort has to go global.
|
|
332
|
-
const TOGGLE = `(async () => {
|
|
333
|
-
const box = [...document.querySelectorAll('label.group-toggle input[type=checkbox]')][0];
|
|
334
|
-
if (!box) return { fatal: 'no grouping toggle on the page — is there more than one brand owner in view?' };
|
|
335
|
-
const before = {
|
|
336
|
-
checked: box.checked,
|
|
337
|
-
headers: [...document.querySelectorAll('tr.group-head')].length,
|
|
338
|
-
cols: [...document.querySelectorAll('table.data thead th')].map(t => (t.textContent || '').trim()),
|
|
339
|
-
};
|
|
340
|
-
box.click();
|
|
341
|
-
await new Promise(r => setTimeout(r, 400));
|
|
342
|
-
const after = {
|
|
343
|
-
checked: box.checked,
|
|
344
|
-
headers: [...document.querySelectorAll('tr.group-head')].length,
|
|
345
|
-
cols: [...document.querySelectorAll('table.data thead th')].map(t => (t.textContent || '').trim()),
|
|
346
|
-
// Every row must still say who it belongs to.
|
|
347
|
-
ownerCells: [...document.querySelectorAll('table.data tbody tr.row')].map(tr => {
|
|
348
|
-
const i = [...document.querySelectorAll('table.data thead th')].findIndex(t => /Brand owner/i.test(t.textContent || ''));
|
|
349
|
-
return i < 0 ? null : (tr.children[i] ? (tr.children[i].textContent || '').trim() : null);
|
|
350
|
-
}),
|
|
351
|
-
// And the read rows must still line up: one cell per column.
|
|
352
|
-
readCols: [...document.querySelectorAll('tr.read-row')].map(tr => tr.children.length),
|
|
353
|
-
};
|
|
354
|
-
return { before, after };
|
|
355
|
-
})()`
|
|
356
|
-
|
|
357
|
-
const toggle = await value(TOGGLE)
|
|
358
|
-
// Put grouping back before the drift probe. The toggle PERSISTS (localStorage,), so leaving it off
|
|
359
|
-
// would have the next pass render a different number of columns and report that as column drift — which
|
|
360
|
-
// is what it did the first time this ran.
|
|
361
|
-
await value(`(async () => { const b = document.querySelector('label.group-toggle input'); if (b && !b.checked) { b.click(); await new Promise(r => setTimeout(r, 300)); } return true })()`)
|
|
362
|
-
|
|
363
|
-
// The drift probe: the SAME page with one run title made much longer. its own acceptance test —
|
|
364
|
-
// "lengthen a run title in the fixture data and nothing except that title moves".
|
|
365
|
-
longTitle = true
|
|
366
|
-
await reload()
|
|
367
|
-
const long = await value(MEASURE)
|
|
368
|
-
|
|
369
|
-
if (shotAt) {
|
|
370
|
-
const shot = await cmd('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })
|
|
371
|
-
const data = shot.result?.result?.data ?? shot.result?.data
|
|
372
|
-
if (data) writeFileSync(shotAt, Buffer.from(data, 'base64'))
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// — SIGNAL THE GROUP, NOT THE PROCESS. `chrome.kill` reaches only the process spawned here;
|
|
376
|
-
// its renderer and GPU children survive and keep writing the profile directory, so the rmSync below
|
|
377
|
-
// races them. Here that loss is SILENT — the rmSync is wrapped — which is why this file never failed
|
|
378
|
-
// CI the way render-check.mjs did, and instead leaked a temp profile and stray processes per run.
|
|
379
|
-
try { process.kill(-chrome.pid, 'SIGKILL') }
|
|
380
|
-
catch { try { chrome.kill('SIGKILL') } catch { /* already gone */ } }
|
|
381
|
-
server.close()
|
|
382
|
-
await new Promise((r) => setTimeout(r, 400))
|
|
383
|
-
if (!keep) { try { rmSync(userDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } catch { /* a stray profile dir is not a test result */ } }
|
|
384
|
-
|
|
385
|
-
// ── verdict ─────────────────────────────────────────────────────────────────────────────────────────
|
|
386
|
-
|
|
387
|
-
const fail = []
|
|
388
|
-
const ok = (cond, msg) => { if (!cond) fail.push(msg) }
|
|
389
|
-
|
|
390
|
-
if (!short || short.fatal) {
|
|
391
|
-
console.error(`clearances-render-check: ${short?.fatal ?? 'the page returned nothing'}`)
|
|
392
|
-
process.exit(1)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
console.log(`measured at ${WIDTH}px — ${short.openedRows} rows opened, ${short.readRows} read rows`)
|
|
396
|
-
// Printed on every run, not only on failure: `overflow: 21px` is the number that told us the table
|
|
397
|
-
// was resolving wider than its wrapper, and it is the first thing to look at when this check goes red.
|
|
398
|
-
console.log(`table ${short.tableWidth}px in a ${short.tableWidth - (short.overflowBy ?? 0)}px wrapper — overflow ${short.overflowBy}px (min-width ${short.tableMinWidth})`)
|
|
399
|
-
console.log(`column share: ${Object.entries(short.share).map(([k, v]) => `${k} ${v}%`).join(' · ')}`)
|
|
400
|
-
|
|
401
|
-
// — the grid is declared, and the reads are in it.
|
|
402
|
-
ok(short.tableLayout === 'fixed', `the table is not fixed-layout (got ${short.tableLayout}) — its columns are still decided by their content`)
|
|
403
|
-
ok(short.readRows > 0, 'no expanded read rows were found — the rows did not open, or a read is still a spanning panel')
|
|
404
|
-
for (const [i, r] of short.reads.entries()) {
|
|
405
|
-
ok(r.cols === short.headText.length,
|
|
406
|
-
`read row ${i} has ${r.cols} cells against ${short.headText.length} columns — it is not in the parent grid`)
|
|
407
|
-
// Every value shares a left edge with the column it belongs to. The twisty cell is skipped: it carries
|
|
408
|
-
// the indent, which is the one deliberate offset.
|
|
409
|
-
for (let c = 1; c < Math.min(r.left.length, short.headLeft.length); c++) {
|
|
410
|
-
ok(Math.abs(r.left[c] - short.headLeft[c]) <= 1,
|
|
411
|
-
`read row ${i}, column ${c} (${short.headText[c] || '?'}) starts at ${r.left[c]} but its header is at ${short.headLeft[c]} — off by ${r.left[c] - short.headLeft[c]}px`)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
ok(!short.headerTruncated, `a header truncated when the rows opened: ${short.headText.join(' · ')}`)
|
|
415
|
-
|
|
416
|
-
// ── the product name, which this file used to certify by not asking ─────────────────────────────────
|
|
417
|
-
//
|
|
418
|
-
// Clearances shows the product's name off the row and falls back to the run's own `stageLabel`, a rung on
|
|
419
|
-
// the retired depth ladder. Both put a string in the same cell, so the check has to read the string. It
|
|
420
|
-
// used to be able to pass on the fallback — its stub answered a contract key the service had stopped
|
|
421
|
-
// sending, the product list arrived empty, and "Depth 4 · 2026-08-02" rendered green. Every fixture row
|
|
422
|
-
// now carries "Depth 4" on the wire ON PURPOSE: if the screen ever reaches for it again, it says so here.
|
|
423
|
-
console.log(`read labels: ${short.readLabels.join(' | ')}`)
|
|
424
|
-
ok(short.readLabels.length > 0, 'no read labels were rendered — the product name cannot be judged from nothing')
|
|
425
|
-
for (const label of short.readLabels) {
|
|
426
|
-
ok(!/Depth\s*\d/i.test(label),
|
|
427
|
-
`a read label reads "${label}" — that is the depth-ladder fallback, and every row was sent a product name`)
|
|
428
|
-
ok(Object.values(PRODUCT_NAME).some((n) => label.startsWith(n)),
|
|
429
|
-
`a read label reads "${label}", which is not one of the offering's product names (${Object.values(PRODUCT_NAME).join(' | ')})`)
|
|
430
|
-
}
|
|
431
|
-
// AND BOTH PRODUCTS IN THE FIXTURE ARE NAMED. Asserting only that no label is a Depth string would pass
|
|
432
|
-
// if every row rendered the same name — the row has to answer per run, which is the whole point of it.
|
|
433
|
-
for (const key of ['global-preliminary-search', 'knockout-search']) {
|
|
434
|
-
ok(short.readLabels.some((l) => l.startsWith(PRODUCT_NAME[key])),
|
|
435
|
-
`no read row named "${PRODUCT_NAME[key]}" — the fixture has a ${key} run, so the screen named it something else`)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// The drift probe.
|
|
439
|
-
if (long && !long.fatal) {
|
|
440
|
-
ok(JSON.stringify(long.headLeft) === JSON.stringify(short.headLeft),
|
|
441
|
-
`lengthening a run title moved the columns: ${short.headLeft.join(',')} → ${long.headLeft.join(',')}`)
|
|
442
|
-
} else {
|
|
443
|
-
fail.push(`the long-title pass returned nothing: ${long?.fatal ?? 'no result'}`)
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// — the toggle. Grouped ⇒ section headers and no owner column; ungrouped ⇒ a column on every row.
|
|
447
|
-
if (!toggle || toggle.fatal) {
|
|
448
|
-
fail.push(`the grouping toggle: ${toggle?.fatal ?? 'no result'}`)
|
|
449
|
-
} else {
|
|
450
|
-
ok(toggle.before.checked === true, 'grouping is not on by default')
|
|
451
|
-
ok(toggle.before.headers > 0, 'grouped, but no brand-owner section headers rendered')
|
|
452
|
-
ok(!toggle.before.cols.some((c) => /Brand owner/i.test(c)), 'grouped, and an owner COLUMN rendered too — the owner is stated twice')
|
|
453
|
-
ok(toggle.after.checked === false, 'clicking the toggle did not turn grouping off')
|
|
454
|
-
ok(toggle.after.headers === 0, `ungrouped, but ${toggle.after.headers} section header(s) survived — the list is not flat`)
|
|
455
|
-
ok(toggle.after.cols.some((c) => /Brand owner/i.test(c)), 'ungrouped, and the brand owner is nowhere — the information did not survive the toggle')
|
|
456
|
-
ok(toggle.after.ownerCells.every((c) => c), `ungrouped, a row does not say who it belongs to: ${JSON.stringify(toggle.after.ownerCells)}`)
|
|
457
|
-
ok(new Set(toggle.after.readCols).size === 1 && toggle.after.readCols[0] === toggle.after.cols.length,
|
|
458
|
-
`ungrouped, the read rows no longer match the grid: ${JSON.stringify(toggle.after.readCols)} cells against ${toggle.after.cols.length} columns`)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// — the date fits, and the table does not overflow.
|
|
462
|
-
ok(short.dateLines.every((n) => n === 1), `the date wraps at ${WIDTH}px — lines per row: ${short.dateLines.join(',')}`)
|
|
463
|
-
// A mark of ordinary length must sit on one line. The fixture's longest ordinary mark is
|
|
464
|
-
// `VIBRANTE FROSTPLUM` — a real E2E mark, not a convenient short one.
|
|
465
|
-
for (const n of short.nameLines ?? []) {
|
|
466
|
-
// A SINGLE mark of ordinary length, which is what asks for. A composite — `VENZY +2 more` — is
|
|
467
|
-
// two facts in one cell and is allowed a second line; the drift-probe name is deliberately absurd.
|
|
468
|
-
if (/\+\d+ more$/.test(n.text) || n.text.length > 34) continue
|
|
469
|
-
ok(n.lines === 1, `the Name cell wraps at ${WIDTH}px for a single mark of ordinary length: ${JSON.stringify(n.text)} took ${n.lines} lines`)
|
|
470
|
-
}
|
|
471
|
-
ok(short.overflowsX === false,
|
|
472
|
-
`the table overflows its wrapper at ${WIDTH}px by ${short.overflowBy}px — table ${short.tableWidth}px, min-width ${short.tableMinWidth}, columns ${JSON.stringify(short.share)}`)
|
|
473
|
-
ok(short.docOverflowsX === false, `the page itself scrolls horizontally at ${WIDTH}px`)
|
|
474
|
-
|
|
475
|
-
if (fail.length) {
|
|
476
|
-
console.error(`\nclearances-render-check FAILED (${fail.length}):`)
|
|
477
|
-
for (const f of fail) console.error(` • ${f}`)
|
|
478
|
-
process.exit(1)
|
|
479
|
-
}
|
|
480
|
-
console.log('clearances-render-check: columns hold, reads are in the grid, the date fits, nothing overflows')
|