clearotron 0.2.2 → 0.2.3
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 +28 -0
- package/.nvmrc +1 -1
- package/INSTALL.md +13 -11
- package/README.md +11 -16
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +4 -0
- package/bin/onboard.mjs +195 -19
- package/bin/start.mjs +154 -8
- package/build-info.json +2 -2
- 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/PORTAL.md +2 -2
- package/docs/README.md +5 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +14 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/driver/CHANGELOG.md +56 -0
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/dev-portal.mjs +2 -2
- package/driver/driver.config.mjs +1 -1
- package/driver/engine/cli-version.mjs +103 -0
- 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 +18 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +5 -5
- package/driver/plain-register.mjs +120 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +40 -1
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +26 -7
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +121 -1
- 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 +2 -2
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +2 -2
- 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/stages.mjs +4 -4
- package/driver/suite-census.json +134 -38
- package/driver/unit-inventory.mjs +184 -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 +2 -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 +28 -10
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
- 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 +2 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/strip-tracker-citations.mjs +29 -6
- package/scripts/test-run.mjs +16 -0
- package/shared/anon-overlay.mjs +3 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/invocation.mjs +28 -0
- package/shared/names-in-force.mjs +2 -0
- package/shared/node-floor.mjs +97 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- 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,1027 +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
|
-
// Does the NEW CLEARANCE composer actually lay out, and do the levers reach the wire?
|
|
5
|
-
//
|
|
6
|
-
// node scripts/composer-render-check.mjs [--keep] [--shot <path>]
|
|
7
|
-
//
|
|
8
|
-
// ── why this exists ──────────────────────────────────────────────────────────────────────────────────
|
|
9
|
-
//
|
|
10
|
-
// Every test in portal-ui asserts on strings or on pure functions. Not one of them can observe a screen
|
|
11
|
-
// that overflows its column, a sticky footer that does not stick, or a control that renders but never
|
|
12
|
-
// reaches `fetch`. The composer was rebuilt into a wizard with a pinned footer and derived levels — all
|
|
13
|
-
// three of those failure modes are now reachable, and all three are invisible to `node --test`.
|
|
14
|
-
//
|
|
15
|
-
// So this serves the REAL built bundle to a REAL browser against a stub of portal-service, drives the
|
|
16
|
-
// controls, and measures. It is the same argument as scripts/render-check.mjs, pointed at the one screen
|
|
17
|
-
// in the portal that can spend money.
|
|
18
|
-
//
|
|
19
|
-
// MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one. Run
|
|
20
|
-
// it as a user with `ulimit -v unlimited`.
|
|
21
|
-
|
|
22
|
-
import { createServer } from 'node:http'
|
|
23
|
-
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
|
|
24
|
-
import { readFileSync, existsSync, writeFileSync } from 'node:fs'
|
|
25
|
-
import { join, extname, dirname } from 'node:path'
|
|
26
|
-
import { fileURLToPath } from 'node:url'
|
|
27
|
-
import { spawn } from 'node:child_process'
|
|
28
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
29
|
-
import { tmpdir } from 'node:os'
|
|
30
|
-
import { productRows } from '../driver/product-rows.mjs'
|
|
31
|
-
import { RETIRED_PRODUCTS } from '../driver/search-policy.mjs'
|
|
32
|
-
|
|
33
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
34
|
-
const DIST = join(HERE, '..', 'portal-ui', 'dist')
|
|
35
|
-
const keep = process.argv.includes('--keep')
|
|
36
|
-
const shotAt = process.argv.includes('--shot') ? process.argv[process.argv.indexOf('--shot') + 1] : null
|
|
37
|
-
|
|
38
|
-
if (!existsSync(join(DIST, 'index.html'))) {
|
|
39
|
-
console.error(`no build at ${DIST} — run: npm run build:ui`)
|
|
40
|
-
process.exit(2)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ── the stub portal ─────────────────────────────────────────────────────────────────────────────────
|
|
44
|
-
// Shapes copied from portal-service's real responses. A client principal with one account, so the
|
|
45
|
-
// composer resolves an owner without a picker.
|
|
46
|
-
|
|
47
|
-
const PLATFORMS = [
|
|
48
|
-
'amazon.com', 'walmart.com', 'target.com', 'gnc.com', 'iherb.com', 'vitaminshoppe.com',
|
|
49
|
-
'kroger.com', 'cvs.com', 'walgreens.com', 'costco.com', 'samsclub.com', 'ebay.com', 'instacart.com',
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
// `components` is the list of component keys whose value is TRUE — portal-service filters the policy's
|
|
53
|
-
// map before it goes on the wire (portal-service.mjs). So `commonLawGrid` is present on a full
|
|
54
|
-
// clearance and absent on a register-only one, which is the subtraction that tells the two apart, and
|
|
55
|
-
// `warnMarks` is 15 on the knockout batch. Both are quoted from PRODUCT_POLICIES rather than simplified:
|
|
56
|
-
// the depth matrix reads exactly these fields, and a stub that omitted them would draw a screen no
|
|
57
|
-
// deployment ever serves.
|
|
58
|
-
// THE ENGINE'S OWN ROWS, not a hand-written copy of them. This used to restate every field of every
|
|
59
|
-
// level here, which is a stub that can silently stop describing the product while still rendering a
|
|
60
|
-
// screen — and the depth matrix reads exactly these fields. `available`/`unavailableNote` are the only
|
|
61
|
-
// additions, because they are deployment state resolved per request and productRows knows nothing of them.
|
|
62
|
-
const stubLevels = () => productRows().map((r) => ({ ...r, available: true, unavailableNote: '' }))
|
|
63
|
-
|
|
64
|
-
const ROUTES = {
|
|
65
|
-
'/portal/api/me': { role: 'client', email: 'demo@example.test', accounts: ['zephyr'], allAccounts: false },
|
|
66
|
-
'/portal/api/searches': {
|
|
67
|
-
account: 'zephyr',
|
|
68
|
-
products: stubLevels(),
|
|
69
|
-
// `nativeLanguage` rides the list row because the composer has to say what geography a saved search
|
|
70
|
-
// accepts while the row is being clicked. Stubbed as the wire sends it.
|
|
71
|
-
recipes: [{ slug: 'launch-screen', label: 'Launch screen', base: 'global-preliminary-search', version: 3, nativeLanguage: false }],
|
|
72
|
-
read: { available: true, maxBrief: 12000, note: null },
|
|
73
|
-
},
|
|
74
|
-
'/portal/api/config/profile': {
|
|
75
|
-
account: 'zephyr',
|
|
76
|
-
profile: {
|
|
77
|
-
platforms: PLATFORMS,
|
|
78
|
-
defaultClasses: [5, 32],
|
|
79
|
-
defaultJurisdictions: [],
|
|
80
|
-
marketplaceDensity: 'High',
|
|
81
|
-
},
|
|
82
|
-
readOnly: {}, contextPack: '', framework: null, derived: null,
|
|
83
|
-
},
|
|
84
|
-
'/portal/api/config/projects': [],
|
|
85
|
-
'/portal/api/usage': {
|
|
86
|
-
account: 'zephyr', today: 1, thisMonth: 4, queued: 0,
|
|
87
|
-
dailyRuns: 3, monthlyRuns: null, maxQueued: null, capped: true,
|
|
88
|
-
},
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Every plan body the page posts, so the check can assert what the levers actually put on the wire. */
|
|
92
|
-
const posted = []
|
|
93
|
-
|
|
94
|
-
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
|
|
95
|
-
|
|
96
|
-
const server = createServer((req, res) => {
|
|
97
|
-
const url = new URL(req.url, 'http://localhost')
|
|
98
|
-
const path = url.pathname
|
|
99
|
-
|
|
100
|
-
// The brief reader. Answers a fixed read so the check can assert what lands in the FORM — the whole
|
|
101
|
-
// point of the feature is that its output is ordinary editable fields, and a string test on the
|
|
102
|
-
// response body would prove nothing about that.
|
|
103
|
-
if (req.method === 'POST' && path === '/portal/api/compose/read') {
|
|
104
|
-
let raw = ''
|
|
105
|
-
req.on('data', (c) => { raw += c })
|
|
106
|
-
req.on('end', () => {
|
|
107
|
-
let body = {}
|
|
108
|
-
try { body = JSON.parse(raw) } catch { /* recorded as empty */ }
|
|
109
|
-
posted.push({ path, body })
|
|
110
|
-
// A brief that ASKS for worldwide gets the lone-claim answer, which is the only way the model can
|
|
111
|
-
// say "everywhere" — worldwide is the empty territory list, so an empty answer would be
|
|
112
|
-
// indistinguishable from silence and would (correctly) leave the form's countries alone. The stub
|
|
113
|
-
// varies on the brief rather than on a counter so the second read is a read, not a fixture swap.
|
|
114
|
-
const worldwideAsk = /worldwide/i.test(String(body?.brief ?? ''))
|
|
115
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
116
|
-
res.end(JSON.stringify({
|
|
117
|
-
read: {
|
|
118
|
-
names: ['AQUAPLUS'], classes: [32], goods: 'energy drinks and sports hydration powders',
|
|
119
|
-
// Bavaria is NOT a territory the composer offers — the screen must own up to dropping it
|
|
120
|
-
// rather than quietly losing it.
|
|
121
|
-
territories: worldwideAsk ? [] : ['United States', 'Bavaria'],
|
|
122
|
-
// WORLDWIDE IS ITS OWN FIELD now: an empty territory list means "the brief does not say" and
|
|
123
|
-
// this boolean means "the brief says everywhere". They were the same answer until, which
|
|
124
|
-
// is why an explicit worldwide over a draft naming France was dropped without a receipt.
|
|
125
|
-
worldwide: worldwideAsk,
|
|
126
|
-
product: worldwideAsk ? 'global-preliminary-search' : 'knockout-search',
|
|
127
|
-
ref: 'M-4471', deadline: '2026-07-24',
|
|
128
|
-
notes: worldwideAsk ? [] : ['The thread also mentions AQUA PLUS as two words — I took the one-word form.'],
|
|
129
|
-
},
|
|
130
|
-
}))
|
|
131
|
-
})
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// A refused run, on demand. `?fail=1` is set by the in-page driver before the one press that must
|
|
136
|
-
// show its reason — the engine's real refusal text, so the assertion reads what a user would.
|
|
137
|
-
if (req.method === 'POST' && path === '/portal/api/run' && url.searchParams.get('fail') === '1') {
|
|
138
|
-
res.writeHead(502, { 'content-type': 'application/json' })
|
|
139
|
-
res.end(JSON.stringify({
|
|
140
|
-
error: 'start_run refused upstream: job rejected — profileKey "sim-praxis" names no known customer '
|
|
141
|
-
+ '— the roster this process can see is [aurora, generic, petcary, zephyr]',
|
|
142
|
-
}))
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (req.method === 'POST' && (path === '/portal/api/run/plan' || path === '/portal/api/run')) {
|
|
147
|
-
let raw = ''
|
|
148
|
-
req.on('data', (c) => { raw += c })
|
|
149
|
-
req.on('end', () => {
|
|
150
|
-
let body = {}
|
|
151
|
-
try { body = JSON.parse(raw) } catch { /* recorded as empty */ }
|
|
152
|
-
posted.push({ path, body })
|
|
153
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
154
|
-
res.end(JSON.stringify(path === '/portal/api/run'
|
|
155
|
-
? { id: 'demo-run' }
|
|
156
|
-
: {
|
|
157
|
-
stageLabel: 'Depth 4', marks: 1, confirmationToken: 'tok', warnings: [],
|
|
158
|
-
caveat: 'Ratings reflect our common law assessment.',
|
|
159
|
-
scope: { jurisdictions: ['China'], jurisdictionsFrom: 'this search', classes: [5, 32], classesFrom: 'the account', platforms: PLATFORMS, platformsAdded: [] },
|
|
160
|
-
turnaround: 'same day',
|
|
161
|
-
}))
|
|
162
|
-
})
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const base = path.split('?')[0]
|
|
167
|
-
if (ROUTES[base] !== undefined) {
|
|
168
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
169
|
-
res.end(JSON.stringify(ROUTES[base]))
|
|
170
|
-
return
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const file = base === '/' || base.startsWith('/portal') && !base.includes('.') ? '/index.html' : base.replace(/^\/portal/, '')
|
|
174
|
-
const full = join(DIST, file)
|
|
175
|
-
if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
|
|
176
|
-
res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
|
|
177
|
-
res.end(readFileSync(full))
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
181
|
-
const port = server.address().port
|
|
182
|
-
const origin = `http://127.0.0.1:${port}`
|
|
183
|
-
|
|
184
|
-
// ── drive it ────────────────────────────────────────────────────────────────────────────────────────
|
|
185
|
-
// Everything below runs INSIDE the page. It waits for the composer to paint, fills it in, exercises the
|
|
186
|
-
// levers, and measures — then posts a plan so the wire body can be checked from the outside.
|
|
187
|
-
|
|
188
|
-
const SCRIPT = `
|
|
189
|
-
(async () => {
|
|
190
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
191
|
-
const settle = async (pred, ms = 6000) => {
|
|
192
|
-
const t0 = Date.now();
|
|
193
|
-
while (Date.now() - t0 < ms) { if (pred()) return true; await sleep(60); }
|
|
194
|
-
return false;
|
|
195
|
-
};
|
|
196
|
-
const txt = () => document.body.innerText;
|
|
197
|
-
// A MISS THROWS, NAMING WHAT IT MISSED. This returned undefined, and the thirteen sites below
|
|
198
|
-
// that dereference it immediately turned a missing button into "Cannot read properties of undefined
|
|
199
|
-
// (reading 'click')" — a message naming no selector, no screen and no step. It was intermittent, so it
|
|
200
|
-
// read as a flaky suite rather than as a driver that could not say what it had not found.
|
|
201
|
-
//
|
|
202
|
-
// Where ABSENCE IS THE ASSERTION, use maybeByText. Converting one of those to the throwing form would
|
|
203
|
-
// crash on the CORRECT path — "the native-language button is hidden on knockout" would die precisely
|
|
204
|
-
// when it is properly hidden — so the two names are kept apart deliberately.
|
|
205
|
-
const maybeByText = (sel, re) => [...document.querySelectorAll(sel)].find((e) => re.test(e.innerText || ''));
|
|
206
|
-
const findByText = (sel, re) => {
|
|
207
|
-
const el = maybeByText(sel, re);
|
|
208
|
-
if (!el) throw new Error('no ' + sel + ' matching ' + re + ' is on screen');
|
|
209
|
-
return el;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
// A DISCARDED settle() IS A WAIT NOBODY CHECKED. settle returns false on timeout and never throws, so
|
|
213
|
-
// eight call sites walked into the DOM assuming a render that had not happened. Line 230 always got
|
|
214
|
-
// this right — it reads the return and names the wait — and this is that shape, reusable.
|
|
215
|
-
const mustSettle = async (pred, ms, what) => {
|
|
216
|
-
if (!await settle(pred, ms)) throw new Error(what + ' (waited ' + ms + 'ms)');
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
// Make ONE run press fail, without the app knowing anything about it. The stub answers 502 for
|
|
220
|
-
// /portal/api/run?fail=1, and this tags that single request — so the refusal path is exercised
|
|
221
|
-
// through the real api client, the real decoder and the real component, which is the only way an
|
|
222
|
-
// assertion about what the user sees means anything.
|
|
223
|
-
const realFetch = window.fetch.bind(window);
|
|
224
|
-
window.fetch = (input, init) => {
|
|
225
|
-
const url = typeof input === 'string' ? input : input.url;
|
|
226
|
-
if (window.__failNextRun && /\\/portal\\/api\\/run$/.test(String(url).split('?')[0])) {
|
|
227
|
-
return realFetch(String(url) + '?fail=1', init);
|
|
228
|
-
}
|
|
229
|
-
return realFetch(input, init);
|
|
230
|
-
};
|
|
231
|
-
const set = (el, v) => {
|
|
232
|
-
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement : HTMLInputElement;
|
|
233
|
-
Object.getOwnPropertyDescriptor(proto.prototype, 'value').set.call(el, v);
|
|
234
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
235
|
-
};
|
|
236
|
-
// Pick one of the four, by NAME. The rows are .pick-row buttons and the name leads each one.
|
|
237
|
-
const pickProduct = (re) => {
|
|
238
|
-
const row = [...document.querySelectorAll('.pick-row')].find((b) => re.test(b.innerText || ''));
|
|
239
|
-
if (!row) throw new Error('no product row matching ' + String(re));
|
|
240
|
-
row.click();
|
|
241
|
-
};
|
|
242
|
-
const out = { steps: [] };
|
|
243
|
-
try {
|
|
244
|
-
|
|
245
|
-
location.hash = '';
|
|
246
|
-
history.pushState({}, '', '/portal/new');
|
|
247
|
-
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
248
|
-
|
|
249
|
-
// The screen OPENS on the two ways in — that fork is the design's, and its absence was the loudest
|
|
250
|
-
// thing missing from the first build.
|
|
251
|
-
if (!await settle(() => /Describe it/.test(txt()) && /Set it up myself/.test(txt()))) {
|
|
252
|
-
return { fatal: 'the entry fork never painted', body: txt().slice(0, 900) };
|
|
253
|
-
}
|
|
254
|
-
out.steps.push('entry fork painted');
|
|
255
|
-
out.entryCards = document.querySelectorAll('.entry-card').length;
|
|
256
|
-
out.startPills = document.querySelectorAll('.start-pill').length;
|
|
257
|
-
|
|
258
|
-
// The context card is up before anything is chosen: who this is for, and what they already carry.
|
|
259
|
-
out.contextCard = Boolean(document.querySelector('.ctx-card'));
|
|
260
|
-
out.inheritedClassChips = [...document.querySelectorAll('.ctx-card .chip')].map((c) => c.innerText.trim());
|
|
261
|
-
out.shopChips = document.querySelectorAll('.ctx-card .chip-mono').length;
|
|
262
|
-
out.showAll = /Show all 13/.test(txt());
|
|
263
|
-
|
|
264
|
-
// ── THE DESCRIBE SIDE ────────────────────────────────────────────────────────────────────────────
|
|
265
|
-
findByText('button', /Describe it/).click();
|
|
266
|
-
await sleep(160);
|
|
267
|
-
out.describePane = /paste the whole thread/.test(txt());
|
|
268
|
-
|
|
269
|
-
// Nothing pasted yet ⇒ nothing to read. The button is dead until there is a brief, so a stray click
|
|
270
|
-
// cannot spend a press of the hourly budget on an empty box.
|
|
271
|
-
out.readDisabledWhenEmpty = maybeByText('button', /Fill it in for me/)?.disabled ?? null;
|
|
272
|
-
|
|
273
|
-
const briefBox = [...document.querySelectorAll('textarea')].find((a) => /obvious blockers/.test(a.placeholder || ''));
|
|
274
|
-
set(briefBox, 'Quick check on AQUAPLUS for energy drinks in the US before Friday, our ref M-4471 — just the obvious blockers.');
|
|
275
|
-
await sleep(120);
|
|
276
|
-
out.readEnabled = !findByText('button', /Fill it in for me/).disabled;
|
|
277
|
-
findByText('button', /Fill it in for me/).click();
|
|
278
|
-
await mustSettle(() => /what i read/i.test(txt()), 4000, 'the read-back of the description never appeared');
|
|
279
|
-
|
|
280
|
-
// What the READ actually did to the FORM. The response body proves nothing — the feature's claim is
|
|
281
|
-
// that its output arrives as ordinary editable fields, so that is what gets read back off the screen.
|
|
282
|
-
out.receiptShown = /what i read/i.test(txt());
|
|
283
|
-
out.receiptLines = [...document.querySelectorAll('.read-receipt li')].map((li) => li.innerText.trim());
|
|
284
|
-
// The brief is NOT consumed: a filler that eats its own input leaves nothing to correct from if it
|
|
285
|
-
// read badly. (It does NOT travel with the request — bodyFor has never sent it.)
|
|
286
|
-
out.briefSurvives = /AQUAPLUS/.test(briefBox.value);
|
|
287
|
-
out.namesFilled = [...document.querySelectorAll('textarea')].some((a) => a.value.trim() === 'AQUAPLUS');
|
|
288
|
-
out.readClassChips = [...document.querySelectorAll('.ctx-card .chip')].map((c) => c.innerText.trim());
|
|
289
|
-
out.deadlineFilled = [...document.querySelectorAll('input[type="date"]')].some((i) => i.value === '2026-07-24');
|
|
290
|
-
out.refFilled = [...document.querySelectorAll('input')].some((i) => i.value === 'M-4471');
|
|
291
|
-
// The brief said "obvious blockers" ⇒ the reader names the Knockout search, and the receipt says the
|
|
292
|
-
// product moved — BY ITS NAME, off the offering. This is the read's most consequential single effect.
|
|
293
|
-
out.productMoved = /Knockout search/.test(txt());
|
|
294
|
-
// A territory this composer does not offer is OWNED, not swallowed.
|
|
295
|
-
out.droppedShown = /Bavaria/.test(txt()) && /not a territory this search offers/i.test(txt());
|
|
296
|
-
out.doubtShown = /two words/i.test(txt());
|
|
297
|
-
|
|
298
|
-
// Move to a clearance before carrying on. The read picked the Knockout search — "just the obvious
|
|
299
|
-
// blockers" — and everything after this point exercises the clearance half of the offering.
|
|
300
|
-
pickProduct(/Multi-country focus search/);
|
|
301
|
-
await sleep(150);
|
|
302
|
-
out.registersRestored = /Multi-country focus search/.test(txt());
|
|
303
|
-
|
|
304
|
-
// ...and the segmented toggle switches back, persistently.
|
|
305
|
-
findByText('button', /Set it up myself/).click();
|
|
306
|
-
// THIS WAIT WAS DEAD. It watched for the text "Start point", which the composer stopped
|
|
307
|
-
// rendering; the only "Start point" left in the tree is a COMMENT in NewClearance.tsx. Because settle
|
|
308
|
-
// returned false silently, it degraded into a flat 6-second sleep and the check still reported
|
|
309
|
-
// "render check passed" — making the wait loud is what surfaced it. Waits on the picker the manual
|
|
310
|
-
// pane actually draws, so it fails when the pane does not paint rather than when a word changes.
|
|
311
|
-
await mustSettle(() => document.querySelectorAll('.pick-row').length > 0, 6000,
|
|
312
|
-
'the manual composer never painted after Set it up myself');
|
|
313
|
-
out.steps.push('manual');
|
|
314
|
-
out.segmented = Boolean(document.querySelector('.segmented-entry'));
|
|
315
|
-
|
|
316
|
-
// The footer must be there before anything is typed — that is the point of a running total.
|
|
317
|
-
out.footerAtRest = /Review clearance/.test(txt());
|
|
318
|
-
out.startingFrom = /starting from ·/i.test(txt());
|
|
319
|
-
// THE FOUR, each with the geography it accepts and the name count it reads — both the SERVER's own
|
|
320
|
-
// figures. A row that stated a limit the wall does not enforce is what this build deleted.
|
|
321
|
-
out.pickerRows = [...document.querySelectorAll('.pick-row')].map((b) => b.innerText.replace(/\\s+/g, ' ').trim());
|
|
322
|
-
out.pickerNamesAllFour = ['Knockout search', 'Global preliminary search', 'Multi-country focus search', 'Full country search']
|
|
323
|
-
.every((n) => out.pickerRows.some((r) => r.includes(n)));
|
|
324
|
-
out.pickerStatesGeography = out.pickerRows.some((r) => /worldwide, and nothing else/.test(r))
|
|
325
|
-
&& out.pickerRows.some((r) => /exactly one country/.test(r));
|
|
326
|
-
out.pickerStatesNameCount = out.pickerRows.some((r) => /up to 8 names/.test(r))
|
|
327
|
-
&& !out.pickerRows.some((r) => /up to 20 names/.test(r));
|
|
328
|
-
|
|
329
|
-
// Saved searches are their OWN group, scrolling, with a retire control per row — not flattened into
|
|
330
|
-
// the product list, which is where the first build put them.
|
|
331
|
-
out.savedHeading = /custom searches/i.test(txt()); // innerText carries text-transform: these labels arrive uppercase
|
|
332
|
-
const savedList = document.querySelector('.saved-list');
|
|
333
|
-
out.savedListScrolls = savedList ? getComputedStyle(savedList).overflowY === 'auto' : null;
|
|
334
|
-
out.savedRetire = document.querySelectorAll('.saved-retire').length;
|
|
335
|
-
|
|
336
|
-
// Fill the required scope. Classes are a LOOKUP now: type a word, pick the class.
|
|
337
|
-
const areas = [...document.querySelectorAll('textarea')];
|
|
338
|
-
set(areas.find((a) => /AQUAPLUS/.test(a.placeholder || '')), 'AQUAPLUS');
|
|
339
|
-
const classInput = [...document.querySelectorAll('input')].find((i) => /Add a class/.test(i.placeholder || ''));
|
|
340
|
-
set(classInput, 'drinks');
|
|
341
|
-
await sleep(160);
|
|
342
|
-
out.classLookup = [...document.querySelectorAll('.typeahead button')].map((b) => b.innerText.trim());
|
|
343
|
-
// 32 is already in scope from the owner's defaults, so it must NOT be offered again; 33 must be.
|
|
344
|
-
out.lookupSkipsChosen = !out.classLookup.some((o) => /^32 · /.test(o));
|
|
345
|
-
out.lookupFindsByWord = out.classLookup.some((o) => /^33 · /.test(o));
|
|
346
|
-
|
|
347
|
-
set(classInput, 'software');
|
|
348
|
-
await sleep(160);
|
|
349
|
-
const pick = [...document.querySelectorAll('.typeahead button')].find((b) => /^9 · /.test(b.innerText));
|
|
350
|
-
out.classLookupFound = Boolean(pick);
|
|
351
|
-
if (pick) pick.click();
|
|
352
|
-
await sleep(150);
|
|
353
|
-
// Adding one PROMOTES the inherited list rather than replacing it — 5 and 32 must survive.
|
|
354
|
-
out.classChipReadsName = /9 · Electrical & software/.test(txt());
|
|
355
|
-
out.inheritedKeptOnOverride = /5 · /.test(txt()) && /32 · /.test(txt());
|
|
356
|
-
|
|
357
|
-
// The deadline is a DATE field, not a text box.
|
|
358
|
-
out.details = Boolean(document.querySelector('details'));
|
|
359
|
-
const det = document.querySelector('details');
|
|
360
|
-
if (det) { det.open = true; await sleep(120); }
|
|
361
|
-
out.deadlineIsDate = [...document.querySelectorAll('input')].some((i) => i.type === 'date');
|
|
362
|
-
|
|
363
|
-
const goods = [...document.querySelectorAll('textarea')].find((a) => /Downloadable software/.test(a.placeholder || ''));
|
|
364
|
-
set(goods, 'energy drinks');
|
|
365
|
-
await sleep(120);
|
|
366
|
-
out.steps.push('filled');
|
|
367
|
-
|
|
368
|
-
// The dense owner: 13 shops + 1 = 14 checks.
|
|
369
|
-
out.checks = (txt().match(/(\\d+) checks per name/) || [])[1] || null;
|
|
370
|
-
|
|
371
|
-
// ── THE PICKER, and the geography that follows from it ────────────────────────────────────────
|
|
372
|
-
//
|
|
373
|
-
// The levers are gone. What replaced them is four rows and a Where panel that CHANGES with the row —
|
|
374
|
-
// worldwide is not a choice on a Global preliminary search, it IS one, so that product has no
|
|
375
|
-
// territory field at all rather than a field whose every use is refused. None of that is observable
|
|
376
|
-
// from a string test; all of it is observable here.
|
|
377
|
-
|
|
378
|
-
// The tier as the FOOTER renders it — innerText, so it carries any text-transform. Read from the
|
|
379
|
-
// element rather than from the whole page: a product name appears in the picker too, and a check that
|
|
380
|
-
// matched there would pass whatever the footer said.
|
|
381
|
-
const tierText = () => {
|
|
382
|
-
const el = document.querySelector('.composer-footer span');
|
|
383
|
-
return el ? el.innerText.trim() : null;
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
pickProduct(/Knockout search/);
|
|
387
|
-
await sleep(160);
|
|
388
|
-
out.knockoutTier = /Knockout search/.test(tierText() || '');
|
|
389
|
-
out.knockoutSweep = /1 broad sweep per name/.test(txt());
|
|
390
|
-
out.nativeHiddenOnKnockout = !maybeByText('button', /Native-language investigation/);
|
|
391
|
-
// the copy changed and the assertion follows it. The OLD string ("not searched; filing counts
|
|
392
|
-
// only") led with the absence and then contradicted it in the same clause; what a Knockout buys at the
|
|
393
|
-
// register is three counts per name in the classes named, and what it does not buy is a reading of the
|
|
394
|
-
// filings behind them. This measures the same thing the ok() below claims: that the card states the
|
|
395
|
-
// counts are part of the product.
|
|
396
|
-
out.knockoutCarriesCounts = /Trademark registers — filing counts per name/.test(txt())
|
|
397
|
-
&& /identical · containing · close variations/.test(txt())
|
|
398
|
-
&& /scoped to your classes/.test(txt());
|
|
399
|
-
out.caseLawStatedNotOffered = /Case law and oppositions — not part of this search/.test(txt())
|
|
400
|
-
&& !maybeByText('button', /^Case law$/);
|
|
401
|
-
out.steps.push('knockout picked');
|
|
402
|
-
|
|
403
|
-
// A GLOBAL PRELIMINARY SEARCH HAS NO WHERE FIELD. The panel states the fact and says why; there is
|
|
404
|
-
// nothing to type into, because there is nothing this product will accept.
|
|
405
|
-
pickProduct(/Global preliminary search/);
|
|
406
|
-
await sleep(160);
|
|
407
|
-
out.globalNoTerritoryInput = !document.querySelector('input[aria-label="Add a territory"]');
|
|
408
|
-
out.globalSaysWhy = /This search is not narrowed/.test(txt());
|
|
409
|
-
out.steps.push('global picked');
|
|
410
|
-
|
|
411
|
-
// A MULTI-COUNTRY FOCUS SEARCH offers regions AND countries, and the one toggle in the offering.
|
|
412
|
-
pickProduct(/Multi-country focus search/);
|
|
413
|
-
await sleep(160);
|
|
414
|
-
const whereBox = () => document.querySelector('input[aria-label="Add a territory"]');
|
|
415
|
-
out.multiHasTerritoryInput = Boolean(whereBox());
|
|
416
|
-
set(whereBox(), 'euro');
|
|
417
|
-
await sleep(180);
|
|
418
|
-
out.multiOffersRegion = [...document.querySelectorAll('.typeahead button')].some((b) => /European Union/.test(b.innerText));
|
|
419
|
-
const nativeBtn = maybeByText('button', /Native-language investigation/);
|
|
420
|
-
out.nativeToggleOffered = Boolean(nativeBtn);
|
|
421
|
-
out.steps.push('multi picked');
|
|
422
|
-
|
|
423
|
-
// …and ONE country is a blocker on it, with the way out NAMED: this is the product boundary a client
|
|
424
|
-
// meets most often, and "pick a Full country search to read France" is the whole of the answer.
|
|
425
|
-
set(whereBox(), 'france');
|
|
426
|
-
await sleep(180);
|
|
427
|
-
const fr = [...document.querySelectorAll('.typeahead button')].find((b) => /^France$/.test(b.innerText.trim()));
|
|
428
|
-
if (fr) fr.click();
|
|
429
|
-
await sleep(180);
|
|
430
|
-
out.oneCountryBlocked = /reads a region, or two or more countries/.test(txt());
|
|
431
|
-
out.oneCountryNamesWayOut = /pick a Full country search to read France/.test(txt());
|
|
432
|
-
const revOne = maybeByText('button', /Review clearance/);
|
|
433
|
-
out.reviewShutOnOneCountry = revOne ? revOne.disabled === true : null;
|
|
434
|
-
out.steps.push('one-country blocker');
|
|
435
|
-
|
|
436
|
-
// A FULL COUNTRY SEARCH offers COUNTRIES ONLY — a region is not a country, and the control fits the
|
|
437
|
-
// product rather than refusing it afterwards. The panel says so at the control.
|
|
438
|
-
pickProduct(/Full country search/);
|
|
439
|
-
await sleep(160);
|
|
440
|
-
out.fullSaysRegionsNotOffered = /Regions are not offered here/.test(txt());
|
|
441
|
-
set(whereBox(), 'euro');
|
|
442
|
-
await sleep(180);
|
|
443
|
-
out.fullOffersNoRegion = ![...document.querySelectorAll('.typeahead button')].some((b) => /European Union/.test(b.innerText));
|
|
444
|
-
out.fullCarriesCaseLaw = /Case law and oppositions — part of this search/.test(txt());
|
|
445
|
-
out.fullNativeAutomatic = /searched automatically/.test(txt()) && !maybeByText('button', /Native-language investigation/);
|
|
446
|
-
set(whereBox(), 'united s');
|
|
447
|
-
await sleep(180);
|
|
448
|
-
const us = [...document.querySelectorAll('.typeahead button')].find((b) => /United States/.test(b.innerText));
|
|
449
|
-
if (us) us.click();
|
|
450
|
-
await sleep(180);
|
|
451
|
-
out.fullOneCountryChip = /United States/.test(txt());
|
|
452
|
-
out.fullReplacesRatherThanStacks = /naming another country replaces it/.test(txt());
|
|
453
|
-
out.steps.push('full country picked');
|
|
454
|
-
|
|
455
|
-
// Back to the shape the wire assertions below read.
|
|
456
|
-
pickProduct(/Multi-country focus search/);
|
|
457
|
-
await sleep(160);
|
|
458
|
-
set(whereBox(), 'china');
|
|
459
|
-
await sleep(180);
|
|
460
|
-
const cn = [...document.querySelectorAll('.typeahead button')].find((b) => /^China$/.test(b.innerText.trim()));
|
|
461
|
-
if (cn) cn.click();
|
|
462
|
-
await sleep(160);
|
|
463
|
-
set(whereBox(), 'japan');
|
|
464
|
-
await sleep(180);
|
|
465
|
-
const jp = [...document.querySelectorAll('.typeahead button')].find((b) => /^Japan$/.test(b.innerText.trim()));
|
|
466
|
-
if (jp) jp.click();
|
|
467
|
-
await sleep(160);
|
|
468
|
-
findByText('button', /Native-language investigation/).click();
|
|
469
|
-
await sleep(160);
|
|
470
|
-
out.steps.push('native language on');
|
|
471
|
-
|
|
472
|
-
// ── layout, measured ──
|
|
473
|
-
const doc = document.documentElement;
|
|
474
|
-
out.horizontalOverflow = doc.scrollWidth - doc.clientWidth;
|
|
475
|
-
|
|
476
|
-
// Find the footer by its COMPUTED position, not by its text: innerText matches every ancestor too,
|
|
477
|
-
// and Array.find returns the outermost one, which measures the whole screen and passes either way.
|
|
478
|
-
const footer = [...document.querySelectorAll('div')].find((e) => getComputedStyle(e).position === 'sticky');
|
|
479
|
-
out.footerIsSticky = Boolean(footer);
|
|
480
|
-
if (footer) {
|
|
481
|
-
window.scrollTo(0, 0);
|
|
482
|
-
await sleep(120);
|
|
483
|
-
const r = footer.getBoundingClientRect();
|
|
484
|
-
out.footerWidth = Math.round(r.width);
|
|
485
|
-
out.pageScrolls = doc.scrollHeight > window.innerHeight;
|
|
486
|
-
// Pinned: with the page scrolled to the top and content below it, a sticky-bottom footer sits at the
|
|
487
|
-
// bottom edge of the viewport rather than off the bottom of the document.
|
|
488
|
-
out.footerInViewport = r.bottom <= window.innerHeight + 1 && r.bottom > 0;
|
|
489
|
-
out.footerBottomGap = Math.round(window.innerHeight - r.bottom);
|
|
490
|
-
}
|
|
491
|
-
out.viewportWidth = window.innerWidth;
|
|
492
|
-
|
|
493
|
-
// ── the wire ──
|
|
494
|
-
findByText('button', /Review clearance/).click();
|
|
495
|
-
await mustSettle(() => /review before you start/i.test(txt()), 5000, 'the review dialog never opened');
|
|
496
|
-
out.reviewOpened = /review before you start/i.test(txt());
|
|
497
|
-
out.reviewIsModal = Boolean(document.querySelector('.modal-scrim'));
|
|
498
|
-
// NO STOPWATCH on the one screen where being hurried is worst. Matched loosely — the old copy was
|
|
499
|
-
// "Good for 9:47." and any m:ss beside the confirm button is the thing being kept off it.
|
|
500
|
-
out.noCountdown = !/good for \\d+:\\d\\d|expired/i.test(txt());
|
|
501
|
-
out.steps.push('planned');
|
|
502
|
-
|
|
503
|
-
// ── a REFUSAL at the run door stays where the eye is ──
|
|
504
|
-
// The stub answers 502 for this one press. The regression: the dialog used to close and write the
|
|
505
|
-
// reason near the bottom of a form several screens tall, so pressing Start while scrolled anywhere
|
|
506
|
-
// else showed the user precisely nothing.
|
|
507
|
-
window.__failNextRun = true;
|
|
508
|
-
findByText('button', /Start clearance/).click();
|
|
509
|
-
await mustSettle(() => /was not started/i.test(txt()), 5000, 'the failure notice never appeared after Start clearance');
|
|
510
|
-
out.failureShown = /was not started/i.test(txt());
|
|
511
|
-
out.failureInsideModal = Boolean(document.querySelector('.modal-scrim')) && (() => {
|
|
512
|
-
const scrim = document.querySelector('.modal-scrim');
|
|
513
|
-
return scrim ? /was not started/i.test(scrim.innerText) : false;
|
|
514
|
-
})();
|
|
515
|
-
out.failureNamesReason = /roster|no known customer|upstream|refused/i.test(txt());
|
|
516
|
-
out.reviewAgainOffered = Boolean(maybeByText('button', /Review again/));
|
|
517
|
-
out.startGoneOnFailure = !maybeByText('button', /^Start clearance/);
|
|
518
|
-
window.__failNextRun = false;
|
|
519
|
-
findByText('button', /Review again/).click();
|
|
520
|
-
// Settle on the failure DISAPPEARING, not on the dialog's title: the title never left the screen
|
|
521
|
-
// (that is the whole point of the fix), so waiting for it passes instantly and proves nothing.
|
|
522
|
-
await mustSettle(() => !/was not started/i.test(txt()), 6000, 'the failure notice never cleared after Review again');
|
|
523
|
-
out.reviewAgainReopened = Boolean(maybeByText('button', /^Start clearance/)) && !/was not started/i.test(txt());
|
|
524
|
-
out.steps.push('refusal shown in the dialog');
|
|
525
|
-
|
|
526
|
-
// ── second pass: a REFUSED SHAPE must not reach the wire ──
|
|
527
|
-
// The blocker text only proves the label. What matters is that NOTHING is posted: a screen that shows
|
|
528
|
-
// a blocker and posts anyway is a screen whose refusal is decoration.
|
|
529
|
-
//
|
|
530
|
-
// WAIT FOR THE BUTTON, NOT FOR ITS NEIGHBOUR. The settle above waits for the
|
|
531
|
-
// failure notice to CLEAR, and the notice can clear before the dialog re-renders its controls. This
|
|
532
|
-
// click then landed on a screen that had not finished and threw "no button matching /Back to edit/ is
|
|
533
|
-
// on screen" — which reads as a missing control and was a missing wait. Measured on CI run
|
|
534
|
-
// 33612945545, self-hosted runner under load, in the same step where home-render-check passed whole.
|
|
535
|
-
//
|
|
536
|
-
// This is the second face of the lesson at the top of this block. That one converted eight sites with
|
|
537
|
-
// NO wait at all; it could not see this one, because this one has a settle beside it — just not one
|
|
538
|
-
// about its own target.
|
|
539
|
-
await mustSettle(() => Boolean(maybeByText('button', /Back to edit/)), 6000,
|
|
540
|
-
'Back to edit never came back after the failure notice cleared');
|
|
541
|
-
findByText('button', /Back to edit/).click();
|
|
542
|
-
await mustSettle(() => /Review clearance/.test(txt()), 5000, 'the composer never came back after Back to edit');
|
|
543
|
-
pickProduct(/Full country search/);
|
|
544
|
-
await sleep(200);
|
|
545
|
-
// Scoped to a TERRITORY chip. The chip-own class is the class chips' class too, so an unscoped search
|
|
546
|
-
// for a Remove button took the first CLASS off the request instead — and every geography assertion
|
|
547
|
-
// after it was then measuring a draft nobody had composed.
|
|
548
|
-
const dropOne = [...document.querySelectorAll('button')]
|
|
549
|
-
.find((b) => /^Remove (United States|China|Japan|France|Germany)$/.test(b.getAttribute('aria-label') || ''));
|
|
550
|
-
if (dropOne) dropOne.click();
|
|
551
|
-
await sleep(220);
|
|
552
|
-
out.noTerritoryBlocked = /reads one country/.test(txt());
|
|
553
|
-
out.refusedReviewShut = findByText('button', /Review clearance/).disabled === true;
|
|
554
|
-
// Pressed anyway. A disabled button that still fires is the shape this whole check exists for, and the
|
|
555
|
-
// server-side assertion below (no plan post ever named a shape the offering refuses) is what proves it
|
|
556
|
-
// did not.
|
|
557
|
-
findByText('button', /Review clearance/).click();
|
|
558
|
-
await sleep(400);
|
|
559
|
-
// CASE-INSENSITIVE, and that is the whole assertion. The dialog's eyebrow reads "Review before you
|
|
560
|
-
// start" in the markup, and .eyebrow carries text-transform:uppercase (portal-ui/src/base.css), so
|
|
561
|
-
// document.body.innerText yields REVIEW BEFORE YOU START. A case-sensitive check never matched, this
|
|
562
|
-
// flag read true whether the dialog opened or not, and the assertion that reads it was a gate with no
|
|
563
|
-
// failing input.
|
|
564
|
-
out.refusedDialogRefused = !/review before you start/i.test(txt());
|
|
565
|
-
out.steps.push('an unacceptable geography refused at the composer');
|
|
566
|
-
|
|
567
|
-
// ── third pass: the KNOCKOUT on the WIRE ──
|
|
568
|
-
// The picker rendering and the footer label prove the screen. What decides which product is run and
|
|
569
|
-
// billed is what reaches the server — and a control that changes the label and sends the old value is
|
|
570
|
-
// exactly the shape of the last two incidents on this screen.
|
|
571
|
-
pickProduct(/Knockout search/);
|
|
572
|
-
await sleep(220);
|
|
573
|
-
out.knockoutReviewable = findByText('button', /Review clearance/).disabled === false;
|
|
574
|
-
findByText('button', /Review clearance/).click();
|
|
575
|
-
await mustSettle(() => /review before you start/i.test(txt()), 5000, 'the review dialog never opened for the knockout'); // the eyebrow renders uppercase
|
|
576
|
-
out.steps.push('planned a knockout');
|
|
577
|
-
|
|
578
|
-
// Leave the screen COMPOSED rather than mid-dialog. The screenshot is evidence for a human, and a
|
|
579
|
-
// modal over a dimmed page shows neither the modal nor the page. Closing it also exercises the one
|
|
580
|
-
// control that must always work here: nothing is spent until Start, so Back must always be reachable.
|
|
581
|
-
const back = maybeByText('button', /Back to edit/);
|
|
582
|
-
if (back) back.click();
|
|
583
|
-
await sleep(220);
|
|
584
|
-
out.modalDismissed = !document.querySelector('.modal-scrim');
|
|
585
|
-
|
|
586
|
-
// ── fourth pass: the marketplaces control, and the comparison table ─────────────────────────────
|
|
587
|
-
pickProduct(/Full country search/);
|
|
588
|
-
await sleep(220);
|
|
589
|
-
set(whereBox(), 'united s');
|
|
590
|
-
await sleep(200);
|
|
591
|
-
const usAgain = [...document.querySelectorAll('.typeahead button')].find((b) => /United States/.test(b.innerText));
|
|
592
|
-
if (usAgain) usAgain.click();
|
|
593
|
-
await sleep(220);
|
|
594
|
-
out.fullRunnable = findByText('button', /Review clearance/).disabled === false;
|
|
595
|
-
|
|
596
|
-
// MARKETPLACES — the add control sits beside the chips, so typing in it must MOVE the estimate.
|
|
597
|
-
// Before, extras rode the wire and the engine ran a grid column for each while checksPerName, the
|
|
598
|
-
// effort bar and the cost band all sat still. Nothing but a real browser can press this.
|
|
599
|
-
const shopsBox = document.querySelector('input[aria-label="Extra marketplaces for this search"]');
|
|
600
|
-
out.extraShopsControlBeside = Boolean(shopsBox);
|
|
601
|
-
if (shopsBox) {
|
|
602
|
-
const checksBefore = (txt().match(/(\\d+) checks per name/) || [])[1];
|
|
603
|
-
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
|
604
|
-
setter.call(shopsBox, 'gnc.com, iherb.com');
|
|
605
|
-
shopsBox.dispatchEvent(new Event('input', { bubbles: true }));
|
|
606
|
-
await sleep(220);
|
|
607
|
-
const checksAfter = (txt().match(/(\\d+) checks per name/) || [])[1];
|
|
608
|
-
out.extraShopsMovedChecks = checksBefore != null && checksAfter != null
|
|
609
|
-
? Number(checksAfter) - Number(checksBefore) : null;
|
|
610
|
-
setter.call(shopsBox, '');
|
|
611
|
-
shopsBox.dispatchEvent(new Event('input', { bubbles: true }));
|
|
612
|
-
await sleep(180);
|
|
613
|
-
}
|
|
614
|
-
// REWRITTEN TO THE OWNER'S COPY (tracker issue 1937). This is the SECOND implementation of the rule
|
|
615
|
-
// — portal-ui/test/screenCopy.test.ts holds the first, against the source — so the copy change reds
|
|
616
|
-
// here too, and re-aiming one without the other leaves a guard asserting a sentence nobody ships.
|
|
617
|
-
//
|
|
618
|
-
// What the old predicate required and the screen no longer says: "column of the sweep" (what a shop
|
|
619
|
-
// DOES) and "another pass per name" (what adding one COSTS). Neither is on the page now. The cost is
|
|
620
|
-
// still visible as a number — extraShopsMovedChecks above drives it and asserts it moves — so this
|
|
621
|
-
// relaxation gives up a sentence, not the fact.
|
|
622
|
-
//
|
|
623
|
-
// Aimed at RENDERED text, not source: JSX collapses the element's newlines and indentation to single
|
|
624
|
-
// spaces before innerText ever sees it, so the whitespace class here is insurance rather than the
|
|
625
|
-
// wrapping the source-reading arm actually needs.
|
|
626
|
-
//
|
|
627
|
-
// DOUBLE the backslashes, as every regex in this file does. This block is a template literal that is
|
|
628
|
-
// evaluated in the page, and a template literal eats an unrecognised escape: a single-backslash \\s+
|
|
629
|
-
// reaches the browser as a literal s+, which turns a correct assertion into a red nobody can explain
|
|
630
|
-
// from reading it. That cost one run here before it was caught locally.
|
|
631
|
-
out.marketplacesSayWhereTheyComeFrom =
|
|
632
|
-
/forced\\s+deep\\s+dive\\s+inherited\\s+from\\s+Brand\\s+Owner/.test(txt())
|
|
633
|
-
&& /common\\s+law\\s+sweeps\\s+everything\\s+it\\s+can\\s+find/.test(txt());
|
|
634
|
-
|
|
635
|
-
// THE COMPARISON, and the scrollbar it must keep to itself. It replaced a delta view that priced a
|
|
636
|
-
// LEVER MOVE — there are no levers, and what a client asks now is "am I buying the right one of the
|
|
637
|
-
// four", which is a comparison.
|
|
638
|
-
const depths = [...document.querySelectorAll('details')].find((d) => /Detailed search comparison table for information/.test(d.innerText));
|
|
639
|
-
out.matrixOffered = Boolean(depths);
|
|
640
|
-
if (depths) {
|
|
641
|
-
depths.open = true;
|
|
642
|
-
await sleep(220);
|
|
643
|
-
const wrap = depths.querySelector('.table-wrap');
|
|
644
|
-
const table = depths.querySelector('table.data');
|
|
645
|
-
out.matrixRows = depths.querySelectorAll('table.data tbody tr').length;
|
|
646
|
-
out.matrixCols = depths.querySelectorAll('table.data thead th').length;
|
|
647
|
-
out.matrixHeaders = [...depths.querySelectorAll('table.data thead th')].map((th) => th.innerText.trim()).filter(Boolean);
|
|
648
|
-
out.matrixScrollsItself = wrap ? getComputedStyle(wrap).overflowX === 'auto' : null;
|
|
649
|
-
// The table is wider than the 720px composer column by design (min-width 760). The wrapper takes
|
|
650
|
-
// that on; the document must not.
|
|
651
|
-
out.matrixWiderThanColumn = wrap && table ? table.scrollWidth > wrap.clientWidth : null;
|
|
652
|
-
// HOW FAR past its wrapper, not just whether. The th rule is nowrap, so when these headers became
|
|
653
|
-
// full product names the table ran ~2x its wrapper and barely two of the columns were visible — a
|
|
654
|
-
// boolean could not tell that from the 40px the 760px min-width has always cost.
|
|
655
|
-
out.matrixOverflowPx = wrap && table ? table.scrollWidth - wrap.clientWidth : null;
|
|
656
|
-
// ── THE ESCAPE MUST BE A CHILD OF THE THING THAT CAPS IT ─────────────
|
|
657
|
-
// The cap is a direct-child selector. The opt-out spent two rounds of fixing applied to a
|
|
658
|
-
// GRANDCHILD, where lifting the max-width asks a box to be wider than the box it lives in — which
|
|
659
|
-
// is why the table stayed at 718px while reading, in the source, as though it had opted out. No
|
|
660
|
-
// assertion over source text can see this; the parent chain can, and only from in here.
|
|
661
|
-
//
|
|
662
|
-
// classList, not a regex: this whole block is a template literal, where a lone backslash-b is the
|
|
663
|
-
// BACKSPACE escape rather than a word boundary. And no backticks in these comments — they end the
|
|
664
|
-
// literal, which is a syntax error that stops the check running at all.
|
|
665
|
-
const wide = document.querySelector('.composer-wide');
|
|
666
|
-
out.wideParentClass = wide?.parentElement?.className ?? null;
|
|
667
|
-
out.wideIsDirectChildOfColumn = !!wide && !!wide.parentElement?.classList.contains('composer-col');
|
|
668
|
-
out.matrixColsVisible = wrap
|
|
669
|
-
? [...depths.querySelectorAll('table.data thead th')]
|
|
670
|
-
.filter((th) => th.offsetLeft + th.offsetWidth <= wrap.clientWidth + 1).length
|
|
671
|
-
: null;
|
|
672
|
-
out.pageStillDoesNotScroll = document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
|
673
|
-
out.matrixNamesNoMachinery = !/jxLanes|commonLawGrid|registerProbe/.test(depths.innerText);
|
|
674
|
-
// THE OFFERING'S OWN WORDS, not a rung: the table states the geography each product accepts and
|
|
675
|
-
// which one carries the case-law reading.
|
|
676
|
-
out.matrixSaysGeography = /exactly one country/.test(depths.innerText)
|
|
677
|
-
&& /worldwide, and nothing else/.test(depths.innerText);
|
|
678
|
-
out.matrixSaysCaseLawProduct = /this is the search that carries it/.test(depths.innerText);
|
|
679
|
-
out.matrixNoRungs = !/Depth \\d/.test(depths.innerText);
|
|
680
|
-
// MARKERS. A glyph with no key is a puzzle, so every one drawn must appear in the legend — checked
|
|
681
|
-
// in the real browser because innerText is where a glyph that failed to render actually disappears.
|
|
682
|
-
out.matrixGlyphsDrawn = [...new Set((depths.innerText.match(/[●◐○]/g) || []))].sort().join('');
|
|
683
|
-
out.matrixLegendShown = /Included/.test(depths.innerText) && /Not on this depth/.test(depths.innerText);
|
|
684
|
-
// The highlighted column is the product that is picked, and there is exactly one.
|
|
685
|
-
out.matrixCurrentCols = depths.querySelectorAll('table.data thead th[aria-current]').length;
|
|
686
|
-
out.matrixCurrentIsHere = /you are here/i.test(depths.innerText); // th is uppercased by CSS
|
|
687
|
-
out.matrixNoNotAvailable = !/Not available/.test(depths.innerText);
|
|
688
|
-
}
|
|
689
|
-
out.steps.push('coherence rules');
|
|
690
|
-
|
|
691
|
-
// ── the worldwide read ──────────────────────────────────────────────────────────────────────────
|
|
692
|
-
//
|
|
693
|
-
// The one instruction a brief can give that REMOVES something from the form. It was structurally
|
|
694
|
-
// impossible before — worldwide was the empty list, the reader could not distinguish "everywhere" from
|
|
695
|
-
// "the brief does not say", and applyRead had no path to []. So an explicit worldwide over a draft
|
|
696
|
-
// naming countries was dropped silently. Both halves are exercised here: the chips must go, and the
|
|
697
|
-
// receipt must say so.
|
|
698
|
-
findByText('button', /^Describe it$/).click();
|
|
699
|
-
await sleep(200);
|
|
700
|
-
const briefBox2 = [...document.querySelectorAll('textarea')].find((a) => /obvious blockers/.test(a.placeholder || ''));
|
|
701
|
-
set(briefBox2, 'Actually make it worldwide, not just the US.');
|
|
702
|
-
await sleep(140);
|
|
703
|
-
findByText('button', /Fill it in for me/).click();
|
|
704
|
-
await mustSettle(() => /the named territories were cleared/.test(txt()), 5000, 'the finished run never named its territories');
|
|
705
|
-
out.worldwideCleared = /the named territories were cleared/.test(txt());
|
|
706
|
-
out.worldwideChipsGone = [...document.querySelectorAll('.chip')].every((c) => !/United States|China/.test(c.innerText));
|
|
707
|
-
out.worldwideChipShown = /Worldwide/.test(txt());
|
|
708
|
-
out.steps.push('worldwide read');
|
|
709
|
-
findByText('button', /Set it up myself/).click();
|
|
710
|
-
await sleep(180);
|
|
711
|
-
|
|
712
|
-
// Leave it on a state a human can read: the deepest search, one country, nothing blocked.
|
|
713
|
-
pickProduct(/Full country search/);
|
|
714
|
-
await sleep(200);
|
|
715
|
-
set(whereBox(), 'united s');
|
|
716
|
-
await sleep(200);
|
|
717
|
-
const usFinal = [...document.querySelectorAll('.typeahead button')].find((b) => /United States/.test(b.innerText));
|
|
718
|
-
if (usFinal) usFinal.click();
|
|
719
|
-
await sleep(220);
|
|
720
|
-
// Scoped to the "Not runnable as set" notice. It used to read the whole page, which was safe until
|
|
721
|
-
// the delta view began quoting blockers' own sentences as the CONSEQUENCE of a move NOT taken — a
|
|
722
|
-
// hypothetical, and the whole point of that view. Reading the notice asks the real question: is the
|
|
723
|
-
// composer blocked, rather than does the word "blocked" appear anywhere on screen.
|
|
724
|
-
const notRunnable = [...document.querySelectorAll('.notice')].find((e) => /Not runnable as set/.test(e.innerText));
|
|
725
|
-
out.leftClean = !notRunnable;
|
|
726
|
-
window.scrollTo(0, 0);
|
|
727
|
-
await sleep(140);
|
|
728
|
-
return out;
|
|
729
|
-
} catch (e) {
|
|
730
|
-
// A THROW MUST NOT READ AS "evaluate returned nothing". The driver is a long script and any step in
|
|
731
|
-
// it can die; without this the harness reported an opaque failure that named no step, and the first
|
|
732
|
-
// assertion to touch a missing key blew up with a TypeError instead. out.steps says how far it got.
|
|
733
|
-
out.fatal = String(e && e.message ? e.message : e);
|
|
734
|
-
out.raw = 'driver threw after: ' + out.steps.join(' -> ');
|
|
735
|
-
// the named throw says WHAT was missing; this says what was on screen instead. Without it a
|
|
736
|
-
// diagnostic fix still loses the diagnosis, which is the whole complaint this change answers.
|
|
737
|
-
out.body = txt().slice(0, 900);
|
|
738
|
-
return out;
|
|
739
|
-
}
|
|
740
|
-
})()
|
|
741
|
-
`
|
|
742
|
-
|
|
743
|
-
const userDir = mkdtempSync(join(tmpdir(), 'composer-check-'))
|
|
744
|
-
|
|
745
|
-
// Chrome's --dump-dom cannot run our driver script, so the driving happens via the DevTools protocol —
|
|
746
|
-
// but standing that up needs no extra dependency: a plain evaluate over the websocket is enough.
|
|
747
|
-
const chrome = spawn('google-chrome', [
|
|
748
|
-
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
749
|
-
`--user-data-dir=${userDir}`, '--window-size=1280,900',
|
|
750
|
-
'--remote-debugging-port=0', `${origin}/portal/new`,
|
|
751
|
-
// — DETACHED so Chrome LEADS A PROCESS GROUP. Its renderer, GPU and zygote processes are
|
|
752
|
-
// separate PIDs, and without a group there is nothing to signal them with.
|
|
753
|
-
], { stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
|
754
|
-
// — and the group dies with THIS script, on every exit it can observe.
|
|
755
|
-
// The teardown below runs on the paths somebody wrote a branch for; a cancelled CI job (SIGTERM),
|
|
756
|
-
// a Ctrl-C, or a throw elsewhere in this file are not among them — and that is where the measured
|
|
757
|
-
// eighty-eight-minute orphan came from.
|
|
758
|
-
reapOnExit(chrome);
|
|
759
|
-
|
|
760
|
-
let devtools = ''
|
|
761
|
-
const wsUrl = await new Promise((resolve, reject) => {
|
|
762
|
-
// 60s, not 20s. The 20s budget was set when CI ran on a paid 4-vCPU larger runner; this workflow now
|
|
763
|
-
// runs on the standard 2-vCPU ubuntu-latest, where a cold Chrome start has no margin in 20s — it
|
|
764
|
-
// failed once in the first 6 runs there with 'chrome did not report a devtools endpoint'.
|
|
765
|
-
//
|
|
766
|
-
// WHAT THE NUMBERS DO NOT SHOW, stated because the tempting claim is wrong: this is NOT established
|
|
767
|
-
// as a regression from the runner change. The same step failed 6 times in 34 runs on ubuntu-latest-m
|
|
768
|
-
// — but all 6 landed inside one 16-minute window on 2026-08-10, across several branches at once,
|
|
769
|
-
// which is an infrastructure incident and not a rate. Outside it: 0 in 28. So the evidence is
|
|
770
|
-
// consistent with the smaller runner being tighter and does not demonstrate it; n is small on both
|
|
771
|
-
// sides. The budget goes up because 20s for a cold Chrome start is thin on any 2-vCPU box, not
|
|
772
|
-
// because a measurement proved causation.
|
|
773
|
-
//
|
|
774
|
-
// And on timeout, SAY WHAT CHROME SAID — the bare message named a symptom and no cause, so the one
|
|
775
|
-
// thing the next reader needs was the one thing it withheld.
|
|
776
|
-
const t = setTimeout(
|
|
777
|
-
() => reject(new Error(`chrome did not report a devtools endpoint within 60s. Its stderr was:\n${devtools || '(nothing)'}`)),
|
|
778
|
-
60000,
|
|
779
|
-
)
|
|
780
|
-
chrome.stderr.on('data', (c) => {
|
|
781
|
-
devtools += c
|
|
782
|
-
const m = devtools.match(/ws:\/\/[^\s]+/)
|
|
783
|
-
if (m) { clearTimeout(t); resolve(m[0]) }
|
|
784
|
-
})
|
|
785
|
-
})
|
|
786
|
-
|
|
787
|
-
const { WebSocket } = await import('node:worker_threads').then(() => ({ WebSocket: globalThis.WebSocket }))
|
|
788
|
-
const ws = new WebSocket(wsUrl)
|
|
789
|
-
let id = 0
|
|
790
|
-
const pending = new Map()
|
|
791
|
-
ws.addEventListener('message', (e) => {
|
|
792
|
-
const msg = JSON.parse(e.data)
|
|
793
|
-
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id) }
|
|
794
|
-
})
|
|
795
|
-
await new Promise((r) => ws.addEventListener('open', r))
|
|
796
|
-
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
797
|
-
|
|
798
|
-
// Attach to the page target and evaluate there.
|
|
799
|
-
const { result: targets } = await send('Target.getTargets')
|
|
800
|
-
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
801
|
-
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
802
|
-
const sessionId = sess.sessionId
|
|
803
|
-
const evalIn = (expr) => new Promise((r) => {
|
|
804
|
-
const i = ++id
|
|
805
|
-
pending.set(i, r)
|
|
806
|
-
ws.send(JSON.stringify({ id: i, sessionId, method: 'Runtime.evaluate', params: { expression: expr, awaitPromise: true, returnByValue: true } }))
|
|
807
|
-
})
|
|
808
|
-
|
|
809
|
-
await new Promise((r) => setTimeout(r, 1500))
|
|
810
|
-
const res = await evalIn(SCRIPT)
|
|
811
|
-
const out = res.result?.result?.value ?? { fatal: 'evaluate returned nothing', raw: JSON.stringify(res).slice(0, 900) }
|
|
812
|
-
|
|
813
|
-
// A picture of the state the driver left it in. Not a test — evidence a human can look at, which is the
|
|
814
|
-
// one thing a measurement cannot be.
|
|
815
|
-
if (shotAt) {
|
|
816
|
-
const shot = await new Promise((r) => {
|
|
817
|
-
const i = ++id
|
|
818
|
-
pending.set(i, r)
|
|
819
|
-
ws.send(JSON.stringify({ id: i, sessionId, method: 'Page.captureScreenshot', params: { format: 'png', captureBeyondViewport: true } }))
|
|
820
|
-
})
|
|
821
|
-
const data = shot.result?.result?.data ?? shot.result?.data
|
|
822
|
-
if (data) writeFileSync(shotAt, Buffer.from(data, 'base64'))
|
|
823
|
-
|
|
824
|
-
// ...and the same screen in dark. Both themes are first-class here, and a contrast that collapses in
|
|
825
|
-
// one of them is invisible to every string assertion in this file.
|
|
826
|
-
await evalIn(`document.documentElement.setAttribute('data-theme','dark'); 'ok'`)
|
|
827
|
-
await new Promise((r) => setTimeout(r, 400))
|
|
828
|
-
const darkShot = await new Promise((r) => {
|
|
829
|
-
const i = ++id
|
|
830
|
-
pending.set(i, r)
|
|
831
|
-
ws.send(JSON.stringify({ id: i, sessionId, method: 'Page.captureScreenshot', params: { format: 'png', captureBeyondViewport: true } }))
|
|
832
|
-
})
|
|
833
|
-
const darkData = darkShot.result?.result?.data ?? darkShot.result?.data
|
|
834
|
-
if (darkData) writeFileSync(shotAt.replace(/\.png$/, '') + '-dark.png', Buffer.from(darkData, 'base64'))
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// — SIGNAL THE GROUP, NOT THE PROCESS. `chrome.kill` reaches only the process spawned here;
|
|
838
|
-
// its renderer and GPU children survive and keep writing the profile directory, so the rmSync below
|
|
839
|
-
// races them. Here that loss is SILENT — the rmSync is wrapped — which is why this file never failed
|
|
840
|
-
// CI the way render-check.mjs did, and instead leaked a temp profile and stray processes per run.
|
|
841
|
-
try { process.kill(-chrome.pid, 'SIGKILL') }
|
|
842
|
-
catch { try { chrome.kill('SIGKILL') } catch { /* already gone */ } }
|
|
843
|
-
server.close()
|
|
844
|
-
// Chrome keeps writing to its profile for a moment after SIGTERM, so a straight rmSync races it and
|
|
845
|
-
// throws ENOTEMPTY — which would fail the check for a reason that has nothing to do with the screen.
|
|
846
|
-
await new Promise((r) => setTimeout(r, 400))
|
|
847
|
-
if (!keep) { try { rmSync(userDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } catch { /* a stray profile dir is not a test result */ } }
|
|
848
|
-
|
|
849
|
-
// ── verdict ─────────────────────────────────────────────────────────────────────────────────────────
|
|
850
|
-
|
|
851
|
-
const planPosts = posted.filter((p) => p.path === '/portal/api/run/plan')
|
|
852
|
-
const planBody = planPosts[0]?.body ?? null
|
|
853
|
-
// The first pass plans TWICE (the refusal drill presses Review again, which mints a fresh token), so
|
|
854
|
-
// this is counted from the END: the counts pass ends with exactly one plan post, and it is the last one
|
|
855
|
-
// the driver makes. Anchoring on planPosts[1] would silently pick up the re-plan instead.
|
|
856
|
-
//
|
|
857
|
-
// THE REGISTERS-ONLY PASS POSTS NOTHING NOW, so there is no body to read from it — what is
|
|
858
|
-
// asserted instead is that no post anywhere carried the retired level.
|
|
859
|
-
const countsBody = planPosts.length > 1 ? planPosts[planPosts.length - 1].body : null
|
|
860
|
-
const fail = []
|
|
861
|
-
const ok = (cond, msg) => { if (!cond) fail.push(msg) }
|
|
862
|
-
|
|
863
|
-
if (out.fatal) {
|
|
864
|
-
console.error('FATAL:', out.fatal)
|
|
865
|
-
console.error(out.body ?? out.raw ?? '')
|
|
866
|
-
process.exit(1)
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
ok(out.entryCards === 2, `the two ways in (Describe it / Set it up myself) did not render — found ${out.entryCards} cards`)
|
|
870
|
-
ok(out.startPills >= 3, `the start-from-a-template pills are missing — found ${out.startPills}`)
|
|
871
|
-
ok(out.describePane, 'the Describe-it pane did not open')
|
|
872
|
-
ok(out.readDisabledWhenEmpty === true, 'Fill it in for me must be dead until there is a brief — an empty press spends a slot of the hourly budget for nothing')
|
|
873
|
-
ok(out.readEnabled === true, 'Fill it in for me never came alive with a brief in the box')
|
|
874
|
-
ok(out.receiptShown, 'a read left no receipt — the reader has no way to check what was taken')
|
|
875
|
-
ok(out.briefSurvives, 'THE brief was consumed by reading it — and there is no undo on this screen, so a bad read has nothing left to correct from')
|
|
876
|
-
ok(out.namesFilled, 'the read did not reach the Names field — the response body is not the feature, the form is')
|
|
877
|
-
ok(out.readClassChips.some((c) => /^32 · /.test(c)), `the read's class never became a chip: ${JSON.stringify(out.readClassChips)}`)
|
|
878
|
-
ok(out.readClassChips.some((c) => /^5 · /.test(c)), 'the read REPLACED the owner\'s inherited classes instead of extending them')
|
|
879
|
-
ok(out.deadlineFilled, 'the read did not reach the date field')
|
|
880
|
-
ok(out.refFilled, 'the read did not reach Your reference')
|
|
881
|
-
ok(out.productMoved, '"just the obvious blockers" did not move the product to a knockout — the most consequential thing a read can do')
|
|
882
|
-
ok(out.droppedShown, 'a territory this composer cannot place was swallowed silently — the user would believe they are paying to search it')
|
|
883
|
-
ok(out.doubtShown, "the model's own doubt was dropped rather than shown")
|
|
884
|
-
ok(out.receiptLines.length >= 4, `the receipt is thin: ${JSON.stringify(out.receiptLines)}`)
|
|
885
|
-
ok(out.segmented, 'the persistent Describe-it / Set-it-up-myself toggle is missing')
|
|
886
|
-
ok(out.contextCard, 'the context card did not render')
|
|
887
|
-
ok(out.inheritedClassChips.some((c) => /^5 · /.test(c)) && out.inheritedClassChips.some((c) => /^32 · /.test(c)),
|
|
888
|
-
`the owner's own classes are not shown as named chips: ${JSON.stringify(out.inheritedClassChips)}`)
|
|
889
|
-
ok(out.shopChips === 6, `the shop list should show six and offer the rest, showed ${out.shopChips}`)
|
|
890
|
-
ok(out.showAll, 'the marketplace list has no show-all — a dead "+N more" is what the ruling forbade')
|
|
891
|
-
ok(out.savedHeading, 'custom searches have no group of their own')
|
|
892
|
-
ok(out.savedListScrolls === true, 'a long custom-search book must scroll rather than push the levers off the page')
|
|
893
|
-
ok(out.savedRetire >= 1, 'a custom search offers no retire control')
|
|
894
|
-
ok(out.classLookupFound, `the class lookup did not find class 9 for "software": ${JSON.stringify(out.classLookup)}`)
|
|
895
|
-
ok(out.lookupFindsByWord, `"drinks" should reach class 33 by its heading: ${JSON.stringify(out.classLookup)}`)
|
|
896
|
-
ok(out.lookupSkipsChosen, 'a class already in scope was offered again')
|
|
897
|
-
ok(out.classChipReadsName, 'a class chip must say what the class IS, not just its number')
|
|
898
|
-
ok(out.inheritedKeptOnOverride, "adding a class dropped the owner's own — an override must extend the inherited list, not replace it")
|
|
899
|
-
ok(out.deadlineIsDate, 'the deadline is still a free-text box')
|
|
900
|
-
ok(out.startingFrom, 'the footer never says what it started from — the "cannot tell what I picked" gap')
|
|
901
|
-
ok(out.reviewIsModal !== false, 'the review step is not the modal the design specifies')
|
|
902
|
-
ok(out.modalDismissed !== false, 'Back to edit did not close the review dialog')
|
|
903
|
-
ok(out.footerAtRest, 'the running total is not on screen before anything is typed')
|
|
904
|
-
ok(out.checks === '14', `dense owner should read 14 checks per name, read ${out.checks}`)
|
|
905
|
-
ok(out.knockoutTier, 'registers off did not become a Knockout')
|
|
906
|
-
ok(out.knockoutSweep, 'a knockout does not say it sweeps marketplaces')
|
|
907
|
-
|
|
908
|
-
// ── THE PICKER: four rows, each stating what it accepts and what it reads ───────────────────────────
|
|
909
|
-
ok(out.pickerNamesAllFour, `the picker does not offer all four searches by name: ${JSON.stringify(out.pickerRows)}`)
|
|
910
|
-
ok(out.pickerStatesGeography, 'a row does not say the geography its search accepts — that is what a client is choosing between')
|
|
911
|
-
ok(out.pickerStatesNameCount,
|
|
912
|
-
'the name count on a row is not the offering\'s figure — a row reading "up to 20 names" over an eight-name wall is what this build deleted')
|
|
913
|
-
ok(out.knockoutTier, 'picking the Knockout search did not name it in the footer')
|
|
914
|
-
ok(out.nativeHiddenOnKnockout, 'the native-language toggle is offered on a knockout, which does not carry it')
|
|
915
|
-
ok(out.knockoutCarriesCounts, 'the Knockout search does not state that it takes the register filing counts')
|
|
916
|
-
ok(out.caseLawStatedNotOffered,
|
|
917
|
-
'case law is either offered as a control or not stated at all — it is what a Full country search IS, so the screen states it and offers nothing to press')
|
|
918
|
-
|
|
919
|
-
// ── GEOGRAPHY FOLLOWS THE PRODUCT, and each control says why at the control ─────────────────────────
|
|
920
|
-
ok(out.globalNoTerritoryInput === true,
|
|
921
|
-
'a Global preliminary search still has a territory field — worldwide is not a choice on it, it IS it, so a field there is a control whose every use is refused')
|
|
922
|
-
ok(out.globalSaysWhy, 'the Where panel does not say WHY there is nothing to set — a control that vanishes with no reason is the oldest complaint about this screen')
|
|
923
|
-
ok(out.multiHasTerritoryInput, 'a Multi-country focus search has no territory field')
|
|
924
|
-
ok(out.multiOffersRegion, 'a Multi-country focus search does not offer regions, which it accepts')
|
|
925
|
-
ok(out.nativeToggleOffered, 'the ONE toggle in the offering is missing from the one product that offers it')
|
|
926
|
-
ok(out.oneCountryBlocked, 'one country on a Multi-country focus search was accepted silently — the engine refuses it, and the user would find out at the gate')
|
|
927
|
-
ok(out.oneCountryNamesWayOut, 'the blocker states no way out — enforcement without an invitation is what this screen exists to stop')
|
|
928
|
-
ok(out.reviewShutOnOneCountry === true, 'Review clearance stayed live on a search the server will refuse')
|
|
929
|
-
ok(out.fullSaysRegionsNotOffered, 'a Full country search does not say that regions are not offered on it')
|
|
930
|
-
ok(out.fullOffersNoRegion === true,
|
|
931
|
-
'a Full country search offered a REGION in its typeahead — the control must fit the product, so the refusal never has to happen')
|
|
932
|
-
ok(out.fullCarriesCaseLaw, 'a Full country search does not state that it carries the case-law reading')
|
|
933
|
-
ok(out.fullNativeAutomatic, 'a Full country search does not state that its native language runs automatically, or wrongly offers a switch for it')
|
|
934
|
-
ok(out.fullOneCountryChip, 'naming a country on a Full country search did not put it in scope')
|
|
935
|
-
ok(out.fullReplacesRatherThanStacks, 'the panel does not say that a second country REPLACES the first — the reader would watch a chip vanish with no reason')
|
|
936
|
-
|
|
937
|
-
ok(out.horizontalOverflow <= 0, `the page scrolls sideways by ${out.horizontalOverflow}px`)
|
|
938
|
-
ok(out.footerIsSticky, 'no sticky footer was rendered at all')
|
|
939
|
-
ok(out.pageScrolls !== false, 'the page did not scroll, so stickiness proves nothing here')
|
|
940
|
-
ok(out.footerInViewport, `the sticky footer is not pinned in the viewport (gap ${out.footerBottomGap}px)`)
|
|
941
|
-
// A FOOTER spans the content column. The first build rendered it as a panel inside the 760px form
|
|
942
|
-
// measure, which is what made it read as a floating box rather than as the end of the page.
|
|
943
|
-
ok(out.footerWidth >= 1000,
|
|
944
|
-
`the footer is ${out.footerWidth}px wide in a ${out.viewportWidth}px viewport — a footer spans the column, a panel does not`)
|
|
945
|
-
ok(out.reviewOpened, 'the review step never opened')
|
|
946
|
-
ok(out.noCountdown, 'a countdown is back on the review dialog — a stopwatch on someone reading a legal summary')
|
|
947
|
-
ok(out.failureShown, 'a refused run showed NOTHING — the exact report from 2026-07-22')
|
|
948
|
-
ok(out.failureInsideModal, 'the refusal rendered outside the dialog again; at the bottom of a tall form it is invisible')
|
|
949
|
-
ok(out.failureNamesReason, 'the refusal did not carry the server\'s reason')
|
|
950
|
-
ok(out.reviewAgainOffered, 'a refused run offers no way forward')
|
|
951
|
-
ok(out.startGoneOnFailure, 'Start is still on screen after the ticket was spent — a dead button')
|
|
952
|
-
ok(out.reviewAgainReopened, 'Review again did not get back to a startable dialog')
|
|
953
|
-
ok(planBody != null, 'no plan body reached the server')
|
|
954
|
-
if (planBody) {
|
|
955
|
-
ok(planBody.product === 'multi-country-focus-search',
|
|
956
|
-
`the picked product must reach the wire, sent ${JSON.stringify(planBody.product)}`)
|
|
957
|
-
ok(planBody.nativeLanguage === true, 'the native-language toggle did not reach the wire')
|
|
958
|
-
ok(planBody.geography && planBody.geography.mode === 'named',
|
|
959
|
-
`the geography MODE must be STATED — "everywhere" and "I said nothing" are different searches, sent ${JSON.stringify(planBody.geography)}`)
|
|
960
|
-
ok(Array.isArray(planBody.jurisdictions) && planBody.jurisdictions.includes('China'), 'the named territory did not reach the wire')
|
|
961
|
-
ok(!('caseLaw' in planBody), 'a caseLaw flag reached the wire — it is what a Full country search IS, not a field')
|
|
962
|
-
}
|
|
963
|
-
ok(out.noTerritoryBlocked, 'a Full country search with no country was accepted silently')
|
|
964
|
-
ok(out.refusedReviewShut === true, 'a search the offering refuses could still be reviewed')
|
|
965
|
-
ok(out.refusedDialogRefused === true, 'the review dialog opened for a search that cannot run')
|
|
966
|
-
ok(out.knockoutReviewable === true, 'a Knockout search could not be reviewed — it is runnable')
|
|
967
|
-
ok(countsBody != null, 'a knockout never reached the server')
|
|
968
|
-
if (countsBody) {
|
|
969
|
-
ok(countsBody.product === 'knockout-search',
|
|
970
|
-
`the picked knockout must reach the wire, sent ${JSON.stringify(countsBody.product)}`)
|
|
971
|
-
}
|
|
972
|
-
// THE INVITATION, MEASURED AT THE WIRE. Every retired level key is checked, DERIVED from the registry
|
|
973
|
-
// rather than hand-listed — a scan naming today's keys keeps passing while a new one walks through.
|
|
974
|
-
ok(!posted.some((p) => RETIRED_PRODUCTS.some((k) => JSON.stringify(p.body ?? {}).includes(JSON.stringify(k)))),
|
|
975
|
-
'a request carrying a retired level key reached the server')
|
|
976
|
-
|
|
977
|
-
// ── the coherence rules ─────────────────────────────────────────────────────────────────────────────
|
|
978
|
-
ok(out.fullRunnable === true, 'a Full country search over one country is blocked — the rule would be refusing its own product')
|
|
979
|
-
ok(out.extraShopsControlBeside, 'the add-a-marketplace control is not on the screen beside the shops')
|
|
980
|
-
ok(out.extraShopsMovedChecks === 2,
|
|
981
|
-
`typing two extra shops should move the checks-per-name figure by 2, it moved by ${out.extraShopsMovedChecks}`)
|
|
982
|
-
ok(out.marketplacesSayWhereTheyComeFrom,
|
|
983
|
-
'the marketplaces column does not say what the shops are or that they are inherited rather than chosen here')
|
|
984
|
-
ok(out.matrixOffered, '"Detailed search comparison table for information" did not render')
|
|
985
|
-
ok(out.matrixRows === 7, `the comparison should carry seven rows, drew ${out.matrixRows}`)
|
|
986
|
-
ok(out.matrixCols === 5, `the matrix should carry a label column plus the four products, drew ${out.matrixCols}`)
|
|
987
|
-
ok(out.matrixScrollsItself === true, 'the matrix wrapper does not scroll — the page would have to instead')
|
|
988
|
-
ok(out.pageStillDoesNotScroll <= 0, `opening the matrix pushed the PAGE sideways by ${out.pageStillDoesNotScroll}px`)
|
|
989
|
-
ok(out.matrixNamesNoMachinery, 'the matrix printed a component name at a client')
|
|
990
|
-
ok(out.matrixSaysGeography, 'the matrix does not state the geography each search accepts, which is half of what distinguishes them')
|
|
991
|
-
ok(out.matrixSaysCaseLawProduct, 'the matrix does not say WHICH search carries the case-law reading')
|
|
992
|
-
ok(out.matrixNoRungs, 'a retired rung survived into the comparison table')
|
|
993
|
-
// ── — THIS THRESHOLD USED TO BLESS THE DEFECT ────────────────────────────
|
|
994
|
-
// It read `<= 60` with a note explaining that the 760px min-width costs ~40px in a 720px column, and
|
|
995
|
-
// its neighbour allowed one column of five to be off-screen for the same reason. Both were true
|
|
996
|
-
// descriptions of a broken state: the block was supposed to have escaped the 720px column entirely and
|
|
997
|
-
// silently had not, so the guard was drawn around the failure and passed it — twice, through two
|
|
998
|
-
// rounds of fixing, while the owner went on reporting the same defect from his own screen.
|
|
999
|
-
//
|
|
1000
|
-
// A guard whose threshold is measured off the broken state cannot report the breakage. The table now
|
|
1001
|
-
// has the column's full measure, so the honest numbers are zero and all of them, and anything else is
|
|
1002
|
-
// the escape coming loose again.
|
|
1003
|
-
ok(out.wideIsDirectChildOfColumn === true,
|
|
1004
|
-
`the width opt-out is not a direct child of .composer-col (its parent is ${JSON.stringify(out.wideParentClass)}) — the cap matches direct children only, so it is inert wherever else it sits`)
|
|
1005
|
-
ok(out.matrixOverflowPx !== null && out.matrixOverflowPx <= 0,
|
|
1006
|
-
`the comparison table runs ${out.matrixOverflowPx}px past its wrapper — it has the column's full width now, so any overflow means the width opt-out is not reaching it`)
|
|
1007
|
-
ok(out.matrixColsVisible === out.matrixCols,
|
|
1008
|
-
`only ${out.matrixColsVisible} of ${out.matrixCols} columns fit — a reader has to side-scroll to see the rest, which is the defect ruled three times`)
|
|
1009
|
-
ok(out.matrixGlyphsDrawn === '○●◐', `all three marker glyphs must render (codepoint order), drew ${JSON.stringify(out.matrixGlyphsDrawn)}`)
|
|
1010
|
-
ok(out.matrixLegendShown, 'glyphs are drawn with no legend explaining them')
|
|
1011
|
-
ok(out.matrixCurrentCols === 1, `exactly one column should be marked current, ${out.matrixCurrentCols} were`)
|
|
1012
|
-
ok(out.matrixCurrentIsHere, 'the current column does not say so in words as well as in colour')
|
|
1013
|
-
ok(out.matrixNoNotAvailable, '"Not available" is retired — it claimed the depth was switched off, not that the axis is absent')
|
|
1014
|
-
ok(out.worldwideCleared, 'an explicit worldwide brief did not clear the named territories, or did it without a receipt line')
|
|
1015
|
-
ok(out.worldwideChipsGone, 'the countries survived a worldwide read — the scope on screen is not what was asked for')
|
|
1016
|
-
ok(out.worldwideChipShown, 'the Where field does not say Worldwide after clearing — an empty box states nothing')
|
|
1017
|
-
ok(out.leftClean, 'the check left the composer in a blocked state')
|
|
1018
|
-
|
|
1019
|
-
console.log(JSON.stringify({ ...out, planBody, countsBody }, null, 2))
|
|
1020
|
-
if (shotAt) writeFileSync(shotAt + '.json', JSON.stringify({ ...out, planBody, countsBody }, null, 2))
|
|
1021
|
-
|
|
1022
|
-
if (fail.length) {
|
|
1023
|
-
console.error('\nFAILED:')
|
|
1024
|
-
for (const f of fail) console.error(' ✗ ' + f)
|
|
1025
|
-
process.exit(1)
|
|
1026
|
-
}
|
|
1027
|
-
console.log('\nrender check passed')
|