clearotron 0.3.2-beta.2 → 0.3.2-beta.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 +10 -0
- package/INSTALL.md +2 -0
- package/README.md +15 -10
- package/bin/onboard.mjs +4 -4
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +54 -54
- package/docs/DELIVERY.md +16 -15
- package/docs/E2E.md +8 -8
- package/docs/GLOSSARY.md +2 -2
- package/docs/INTAKE.md +11 -11
- package/docs/ONBOARDING.md +17 -16
- package/docs/PORTAL.md +18 -16
- package/docs/README.md +10 -10
- package/docs/SECURITY.md +20 -20
- package/docs/architecture/01-product-overview.md +17 -16
- package/docs/architecture/02-architecture.md +6 -6
- package/docs/architecture/03-run-lifecycle.md +7 -7
- package/docs/architecture/04-configuration-reference.md +14 -13
- package/docs/architecture/05-config-governance.md +30 -29
- package/docs/architecture/05-customer-profiles.md +35 -35
- package/docs/architecture/06-operations-runbook.md +20 -20
- package/docs/architecture/07-quality-and-audit.md +17 -17
- package/docs/architecture/08-development-guide.md +3 -3
- package/docs/architecture/09-security-and-data.md +27 -27
- package/docs/architecture/README.md +1 -1
- package/docs/branding.md +8 -3
- package/docs/configuration.md +18 -18
- package/docs/writing-standard.md +3 -3
- package/driver/CHANGELOG.md +27 -0
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +34 -4
- package/driver/suite-census.json +110 -44
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +6 -6
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-BsbasHjM.js → index-Bki5jT_N.js} +3376 -2971
- package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-De2RFLbT.css} +972 -205
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +287 -105
- package/scripts/release-note-required.mjs +16 -2
- package/scripts/settings-render-check.mjs +575 -0
- package/shared/brand.mjs +29 -0
- package/shared/connect-clients.mjs +99 -47
- package/shared/names-in-force.mjs +1 -0
- package/shared/stdio-connect.mjs +16 -2
- package/shared/writing-standard-classes.mjs +34 -3
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
-
// Ask AI on a report, pressed in a real browser, in the
|
|
4
|
+
// Ask AI on a report, pressed in a real browser, in the states a reader actually meets.
|
|
5
5
|
//
|
|
6
|
-
// node scripts/ask-ai-render-check.mjs [--keep] [--
|
|
6
|
+
// node scripts/ask-ai-render-check.mjs [--keep] [--shot-dir <dir>] [--width 1280]
|
|
7
7
|
//
|
|
8
8
|
// ── why a browser, and why a separate one ────────────────────────────────────────────────────────────
|
|
9
9
|
//
|
|
10
10
|
// The owner watched a lawyer press this button and give up on what opened. Nothing in portal-ui can ask
|
|
11
11
|
// what a press leaves a reader looking at: `node --test` with type stripping carries no DOM, so the
|
|
12
|
-
// source-text arms over
|
|
13
|
-
// that renders from one that renders behind a closed
|
|
14
|
-
// question from one that carries
|
|
12
|
+
// source-text arms over the control can prove a string is in a file and no more. They cannot tell a
|
|
13
|
+
// panel that renders from one that renders behind a closed one, and they cannot tell a link that carries
|
|
14
|
+
// the selected question from one that carries the first question whatever was selected.
|
|
15
15
|
//
|
|
16
16
|
// Separate from revisit-render-check.mjs, which drives the same screen: that one counts REQUESTS, and
|
|
17
|
-
// opening
|
|
17
|
+
// opening panels inside its measurement windows would change the number it exists to measure.
|
|
18
18
|
//
|
|
19
|
-
// ── the
|
|
19
|
+
// ── the states, and why the third is the one to get wrong ────────────────────────────────────────────
|
|
20
20
|
//
|
|
21
|
-
// connected a
|
|
21
|
+
// connected a panel — the report's name, four questions, "Open in Claude", and a line saying
|
|
22
|
+
// the question is typed in and not sent
|
|
22
23
|
// never connected a panel — what to do once, Set it up, and "Already connected? Ask anyway"
|
|
23
24
|
// no connector nothing is drawn. A button that can do nothing is not a button.
|
|
24
25
|
//
|
|
25
26
|
// The server answers `aiConnected` as true, false, or NULL — null meaning the installation could not be
|
|
26
|
-
// asked. A null draws the panel, the same as false, and this check drives it as its own deck
|
|
27
|
-
// trusting that they look alike: they render the same by a decision about which way to be
|
|
28
|
-
// deck that only ever saw `false` would go green on a build that crashed on null.
|
|
27
|
+
// asked. A null draws the connect panel, the same as false, and this check drives it as its own deck
|
|
28
|
+
// rather than trusting that they look alike: they render the same by a decision about which way to be
|
|
29
|
+
// wrong, and a deck that only ever saw `false` would go green on a build that crashed on null.
|
|
30
|
+
//
|
|
31
|
+
// ── and the way back ─────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// "Set it up" remembers the report and opens Connect your AI marked as reached from a report. Once the
|
|
34
|
+
// reader is connected, that page offers "Continue with <mark>", which must land on the report with the
|
|
35
|
+
// panel already open in its connected form. Both halves are pressed here, in one browser, because the
|
|
36
|
+
// note lives in that browser's storage and nowhere else.
|
|
37
|
+
//
|
|
38
|
+
// With --shot-dir, the two panels a reader meets are captured in light and dark, at the page's full
|
|
39
|
+
// height (a beyond-viewport capture paints the sticky header where the viewport put it).
|
|
29
40
|
//
|
|
30
41
|
// MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one.
|
|
31
42
|
|
|
32
43
|
import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
|
|
33
44
|
import { reapOnExit } from "../shared/reap-on-exit.mjs"; // a detached group dies with this script
|
|
34
45
|
import { browserRun } from "../shared/browser-temp-root.mjs";
|
|
46
|
+
import { connectOffers, offersForWire } from '../shared/connect-clients.mjs'
|
|
35
47
|
import { createServer } from 'node:http'
|
|
36
48
|
import { readFileSync, existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
37
49
|
import { join, extname, dirname } from 'node:path'
|
|
@@ -41,8 +53,10 @@ import { spawn } from 'node:child_process'
|
|
|
41
53
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
42
54
|
const DIST = join(HERE, '..', 'portal-ui', 'dist')
|
|
43
55
|
const keep = process.argv.includes('--keep')
|
|
44
|
-
const
|
|
45
|
-
|
|
56
|
+
const argValue = (flag) => (process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : null)
|
|
57
|
+
// `--shots` is the older spelling, kept so a command someone already has keeps working.
|
|
58
|
+
const shotsDir = argValue('--shot-dir') ?? argValue('--shots')
|
|
59
|
+
const WIDTH = process.argv.includes('--width') ? Number(argValue('--width')) : 1280
|
|
46
60
|
|
|
47
61
|
if (!existsSync(join(DIST, 'index.html'))) {
|
|
48
62
|
console.error(`no build at ${DIST} — run: npm run build:ui`)
|
|
@@ -52,10 +66,14 @@ if (shotsDir) mkdirSync(shotsDir, { recursive: true })
|
|
|
52
66
|
|
|
53
67
|
// ── the fixture ─────────────────────────────────────────────────────────────────────────────────────
|
|
54
68
|
//
|
|
55
|
-
// One delivered clearance. The mark and the date are what the
|
|
56
|
-
//
|
|
69
|
+
// One delivered clearance. The mark and the date are what the questions are built from, so they are the
|
|
70
|
+
// facts this file cares about — and the date is deliberately one whose ISO spelling and its readable
|
|
57
71
|
// form differ in both fields, so a formatter that printed the wrong part could not pass by coincidence.
|
|
58
72
|
//
|
|
73
|
+
// THE ISSUE STAMP IS 22:30 UTC ON THE 14TH, which is already the 15th in Zurich, where the report's own
|
|
74
|
+
// Issued stamp is written. A header that sliced the UTC stamp would print the searched date twice; the
|
|
75
|
+
// right one prints two different days.
|
|
76
|
+
//
|
|
59
77
|
// `productName` is resolved through the same expression portal-service.mjs evaluates, never typed: the
|
|
60
78
|
// sibling instruments' rule, and it holds here for the same reason.
|
|
61
79
|
const { reportIdentityFor } = await import('../driver/search-policy.mjs')
|
|
@@ -65,33 +83,47 @@ const NAME = 'Acme'
|
|
|
65
83
|
const RUN_ID = 'tmpa-askai-1'
|
|
66
84
|
const MARK = 'VENZY'
|
|
67
85
|
const ISO_DATE = '2026-09-14'
|
|
86
|
+
const ISSUED_AT = '2026-09-14T22:30:00.000Z'
|
|
87
|
+
const ISSUED_DAY = '2026-09-15'
|
|
68
88
|
const READABLE_DATE = '14 September'
|
|
69
|
-
const
|
|
89
|
+
const REPORT = `the ${MARK} clearance from ${READABLE_DATE}`
|
|
90
|
+
// The four questions as a reader's assistant receives them — the rows' words are shorter, because the
|
|
91
|
+
// panel's first line already names the report.
|
|
92
|
+
const QUESTIONS = [
|
|
93
|
+
{ label: 'Brief me on this clearance', typed: `Brief me on ${REPORT}.` },
|
|
94
|
+
{ label: 'Explain the main risks', typed: `Explain the main risks in ${REPORT}.` },
|
|
95
|
+
{ label: 'What needs further investigation?', typed: `What needs further investigation in ${REPORT}?` },
|
|
96
|
+
{ label: 'How would narrower goods change the assessment?', typed: `How would narrower goods change the assessment in ${REPORT}?` },
|
|
97
|
+
]
|
|
70
98
|
|
|
71
99
|
const PRODUCT = 'global-preliminary-search'
|
|
100
|
+
const PRODUCT_NAME = reportIdentityFor(PRODUCT).identity
|
|
72
101
|
const RUN = {
|
|
73
102
|
account: KEY, kind: 'clearance', state: 'delivered', band: 'Manageable', tone: 'low',
|
|
74
103
|
bands: [{ label: 'Severe', tone: 'severe' }, { label: 'Medium', tone: 'medium' }, { label: 'Manageable', tone: 'low' }],
|
|
75
104
|
marks: [], reportSchema: 2, held: false, report: `/portal/report/${RUN_ID}/`,
|
|
76
105
|
step: null, stepN: null, stepTotal: null, reason: null, failedStage: null,
|
|
77
106
|
pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
|
|
78
|
-
projectKey: null, projectName: null, markName: MARK, title: MARK, date: ISO_DATE,
|
|
79
|
-
product: PRODUCT, stageLabel: 'Depth 4', productName:
|
|
107
|
+
projectKey: null, projectName: null, markName: MARK, title: MARK, date: ISO_DATE, issuedAt: ISSUED_AT,
|
|
108
|
+
product: PRODUCT, stageLabel: 'Depth 4', productName: PRODUCT_NAME, runId: RUN_ID,
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
// ── the decks ───────────────────────────────────────────────────────────────────────────────────────
|
|
83
112
|
//
|
|
84
113
|
// Only the mcp-access body varies. Everything else about the screen is held still, so a difference in
|
|
85
|
-
// what the button does is a difference in what the deployment answered and nothing else.
|
|
114
|
+
// what the button does is a difference in what the deployment answered and nothing else. The offers are
|
|
115
|
+
// composed through the one table, as the route composes them, because Connect your AI renders them when
|
|
116
|
+
// "Set it up" and "Continue" cross to it.
|
|
86
117
|
const WIRED = { url: 'https://clients-mcp.example/mcp', keyUrl: null, email: 'reader@example-firm.com' }
|
|
118
|
+
const offersAt = (url) => offersForWire(connectOffers({ stdioRoutes: {}, publicAddress: url, operator: WIRED.email, door: url ? 'sign-in' : null }))
|
|
87
119
|
const DECKS = {
|
|
88
|
-
connected: { ...WIRED, enabled: true, stdio: null, aiConnected: true },
|
|
89
|
-
neverBefore: { ...WIRED, enabled: true, stdio: null, aiConnected: false },
|
|
90
|
-
cannotTell: { ...WIRED, enabled: true, stdio: null, aiConnected: null },
|
|
91
|
-
noConnector: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null },
|
|
120
|
+
connected: { ...WIRED, enabled: true, stdio: null, aiConnected: true, offers: offersAt(WIRED.url) },
|
|
121
|
+
neverBefore: { ...WIRED, enabled: true, stdio: null, aiConnected: false, offers: offersAt(WIRED.url) },
|
|
122
|
+
cannotTell: { ...WIRED, enabled: true, stdio: null, aiConnected: null, offers: offersAt(WIRED.url) },
|
|
123
|
+
noConnector: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null, offers: [] },
|
|
92
124
|
// A laptop install: no published door, but a local route. This reader must be offered the control —
|
|
93
125
|
// it is the case the old panel stranded, because every address it could show was null.
|
|
94
|
-
localOnly: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, aiConnected: true,
|
|
126
|
+
localOnly: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, aiConnected: true, offers: [],
|
|
95
127
|
stdio: { command: 'node server.mjs', note: 'from this install', verify: 'it prints ready' } },
|
|
96
128
|
}
|
|
97
129
|
let deck = 'connected'
|
|
@@ -106,13 +138,19 @@ const server = createServer((req, res) => {
|
|
|
106
138
|
if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
|
|
107
139
|
if (p === '/portal/api/runs') return json(res, { runs: [RUN] })
|
|
108
140
|
if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
|
|
109
|
-
if (p === '/portal/api/mcp-access') return json(res,
|
|
141
|
+
if (p === '/portal/api/mcp-access') return json(res, DECKS[deck])
|
|
110
142
|
if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
|
|
111
143
|
// A real document, because a 404 in the frame changes what Result draws around it. Its CONTENT is not
|
|
112
|
-
// this file's subject — render-check.mjs owns the report's own layout.
|
|
144
|
+
// this file's subject — render-check.mjs owns the report's own layout. It announces the commands and
|
|
145
|
+
// the height a published report's bridge announces, so the header draws the Export menu a reader meets.
|
|
113
146
|
if (p.startsWith('/portal/report/')) {
|
|
114
147
|
res.writeHead(200, { 'content-type': 'text/html' })
|
|
115
|
-
return res.end(`<!doctype html><meta charset="utf-8"><title>report</title
|
|
148
|
+
return res.end(`<!doctype html><meta charset="utf-8"><title>report</title>
|
|
149
|
+
<body style="margin:0;font-family:sans-serif;background:#fff"><div style="padding:32px"><h1>${MARK}</h1><p>fixture report.</p></div>
|
|
150
|
+
<script>
|
|
151
|
+
parent.postMessage({ source: 'cordillera-report', type: 'controls', commands: ['exportPDF', 'pickAll', 'openAll'] }, '*');
|
|
152
|
+
parent.postMessage({ source: 'cordillera-report', type: 'height', height: 640 }, '*');
|
|
153
|
+
</script></body>`)
|
|
116
154
|
}
|
|
117
155
|
const base = p.split('?')[0]
|
|
118
156
|
const file = base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')
|
|
@@ -166,67 +204,112 @@ const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr,
|
|
|
166
204
|
|
|
167
205
|
// ── what is measured, in the page ───────────────────────────────────────────────────────────────────
|
|
168
206
|
//
|
|
169
|
-
// READ OFF THE RENDERED DOM, never off a class name. A
|
|
207
|
+
// READ OFF THE RENDERED DOM, never off a class name alone. A row that exists behind `display:none` and
|
|
170
208
|
// one a reader can press look identical to a selector; `offsetParent` and a non-zero rect are what tell
|
|
171
209
|
// them apart, and that difference is the whole subject of this file.
|
|
172
210
|
//
|
|
173
|
-
// WINDOW.OPEN IS REPLACED, NOT FOLLOWED. Pressing
|
|
174
|
-
// route to (`--host-resolver-rules`) — and following it would leave the drive on a page that is
|
|
175
|
-
// one being measured. The stub records the href instead, so the assertion is about the link the
|
|
176
|
-
// actually carries rather than about a string in the source.
|
|
211
|
+
// WINDOW.OPEN IS REPLACED, NOT FOLLOWED. Pressing "Open in Claude" opens claude.ai, which this browser
|
|
212
|
+
// has no route to (`--host-resolver-rules`) — and following it would leave the drive on a page that is
|
|
213
|
+
// not the one being measured. The stub records the href instead, so the assertion is about the link the
|
|
214
|
+
// button actually carries rather than about a string in the source.
|
|
177
215
|
const PROBE = `(async () => {
|
|
178
216
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
217
|
+
const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
179
218
|
window.__opened = [];
|
|
180
219
|
if (!window.__openStubbed) { window.open = (href) => { window.__opened.push(href); return null; }; window.__openStubbed = true; }
|
|
181
220
|
|
|
182
221
|
const visible = (el) => !!el && !!el.offsetParent && el.getBoundingClientRect().width > 0;
|
|
183
222
|
const anchor = document.querySelector('[data-ask-ai]');
|
|
184
|
-
const button =
|
|
223
|
+
const button = anchor ? anchor.querySelector(':scope > button') : null;
|
|
185
224
|
if (!button || !visible(button)) return { drawn: false, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null };
|
|
186
225
|
|
|
187
|
-
|
|
188
|
-
await sleep(220);
|
|
226
|
+
const openOnArrival = !!anchor.querySelector('.float');
|
|
227
|
+
if (!openOnArrival) { button.click(); await sleep(220); }
|
|
189
228
|
|
|
190
|
-
const float =
|
|
191
|
-
if (!float) return { drawn: true, opened: false, anchorState: anchor
|
|
229
|
+
const float = anchor.querySelector('.float');
|
|
230
|
+
if (!float) return { drawn: true, opened: false, anchorState: anchor.getAttribute('data-ask-ai') };
|
|
192
231
|
|
|
193
|
-
const
|
|
194
|
-
const
|
|
232
|
+
const b = button.getBoundingClientRect();
|
|
233
|
+
const f = float.getBoundingClientRect();
|
|
195
234
|
const text = (float.innerText || '').trim();
|
|
196
|
-
const width = Math.round(float.getBoundingClientRect().width);
|
|
197
|
-
// A divider is a one-pixel-high element between the assistants and the quiet item. Measured, not
|
|
198
|
-
// matched by class: the mockup's separator is a rule, and a rule is a height.
|
|
199
|
-
const seps = [...float.querySelectorAll('div')].filter(d => {
|
|
200
|
-
const r = d.getBoundingClientRect();
|
|
201
|
-
return r.height <= 2 && r.height > 0 && r.width > 40;
|
|
202
|
-
}).length;
|
|
203
|
-
|
|
204
235
|
return {
|
|
205
|
-
drawn: true, opened: true,
|
|
206
|
-
|
|
236
|
+
drawn: true, opened: true, openOnArrival,
|
|
237
|
+
role: float.getAttribute('role'), label: float.getAttribute('aria-label'),
|
|
238
|
+
anchorState: anchor.getAttribute('data-ask-ai'),
|
|
239
|
+
buttonText: flat(button.textContent), buttonAria: button.getAttribute('aria-label'),
|
|
240
|
+
head: flat(float.querySelector('.ask-ai-head') ? float.querySelector('.ask-ai-head').textContent : ''),
|
|
241
|
+
group: float.querySelector('[role="radiogroup"]') ? float.querySelector('[role="radiogroup"]').getAttribute('aria-label') : null,
|
|
242
|
+
radios: [...float.querySelectorAll('[role="radio"]')].filter(visible).map(r => ({ text: flat(r.textContent), checked: r.getAttribute('aria-checked') })),
|
|
243
|
+
primary: float.querySelector('.btn-primary') ? flat(float.querySelector('.btn-primary').textContent) : null,
|
|
244
|
+
note: flat(float.querySelector('.ask-ai-note') ? float.querySelector('.ask-ai-note').textContent : ''),
|
|
245
|
+
items: [...float.querySelectorAll('button')].filter(visible).map(x => flat(x.textContent)),
|
|
246
|
+
text, width: Math.round(f.width),
|
|
247
|
+
rightAligned: Math.abs(Math.round(f.right) - Math.round(b.right)) <= 1,
|
|
248
|
+
below: f.top >= b.bottom - 1,
|
|
207
249
|
hasAddress: /mcp|https?:\\/\\//i.test(text),
|
|
208
250
|
hasCopy: /copy/i.test(text),
|
|
209
251
|
};
|
|
210
252
|
})()`
|
|
211
253
|
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const
|
|
254
|
+
// THE HEADER A READER READS ABOVE THE REPORT: the breadcrumb, the labelled line, the actions in order, and
|
|
255
|
+
// the top bar, which names no company on a report.
|
|
256
|
+
const HEADER = `(() => {
|
|
257
|
+
const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
258
|
+
const head = document.querySelector('.report-head');
|
|
259
|
+
const h1 = head ? head.querySelector('h1') : null;
|
|
260
|
+
const line = h1 ? h1.nextElementSibling : null;
|
|
261
|
+
const crumbs = document.querySelector('.report-crumbs');
|
|
262
|
+
return {
|
|
263
|
+
mark: h1 ? flat(h1.textContent) : null,
|
|
264
|
+
line: line ? flat(line.textContent) : null,
|
|
265
|
+
crumbs: crumbs ? [...crumbs.children].map(c => flat(c.textContent)) : [],
|
|
266
|
+
actions: head ? [...head.querySelectorAll(':scope button, :scope a')].filter(x => !x.closest('.float')).map(x => flat(x.textContent)).filter(Boolean) : [],
|
|
267
|
+
actionKinds: head ? [...head.querySelectorAll('[data-ask-ai] > button, .report-action')].map(x => x.className) : [],
|
|
268
|
+
topbar: flat(document.querySelector('.topbar h1') ? document.querySelector('.topbar h1').textContent : ''),
|
|
269
|
+
followUp: /Ask a follow-up/i.test(document.body.innerText || ''),
|
|
270
|
+
};
|
|
271
|
+
})()`
|
|
272
|
+
|
|
273
|
+
// ONE QUESTION, CHOSEN AND SENT TO THE ASSISTANT: the row is pressed, the choice is read back, and the
|
|
274
|
+
// button is pressed. PRESSING CLOSES THE PANEL, which is correct and is why this re-opens it first.
|
|
275
|
+
const ASK = (index) => `(async () => {
|
|
216
276
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
217
|
-
|
|
218
|
-
|
|
277
|
+
let float = document.querySelector('[data-ask-ai] .float');
|
|
278
|
+
if (!float) {
|
|
279
|
+
const opener = document.querySelector('[data-ask-ai] > button');
|
|
219
280
|
if (!opener) return { fatal: 'the control is gone from the header' };
|
|
220
281
|
opener.click();
|
|
221
282
|
await sleep(220);
|
|
283
|
+
float = document.querySelector('[data-ask-ai] .float');
|
|
222
284
|
}
|
|
223
|
-
const float = document.querySelector('[data-ask-ai] .float');
|
|
224
285
|
if (!float) return { fatal: 'nothing open to press' };
|
|
225
|
-
const
|
|
226
|
-
if (!
|
|
227
|
-
|
|
286
|
+
const radio = float.querySelectorAll('[role="radio"]')[${index}];
|
|
287
|
+
if (!radio) return { fatal: 'no question ${index + 1}' };
|
|
288
|
+
radio.click();
|
|
289
|
+
await sleep(150);
|
|
290
|
+
float = document.querySelector('[data-ask-ai] .float');
|
|
291
|
+
const checked = [...float.querySelectorAll('[role="radio"]')].map(r => r.getAttribute('aria-checked'));
|
|
292
|
+
const go = float.querySelector('.btn-primary');
|
|
293
|
+
if (!go) return { fatal: 'no button to open the assistant' };
|
|
294
|
+
go.click();
|
|
228
295
|
await sleep(200);
|
|
229
|
-
return { opened: window.__opened || [] };
|
|
296
|
+
return { opened: window.__opened || [], checked, closed: !document.querySelector('[data-ask-ai] .float') };
|
|
297
|
+
})()`
|
|
298
|
+
|
|
299
|
+
// THE ARROW KEYS MOVE THE CHOICE, the way a radio group answers them.
|
|
300
|
+
const ARROW = `(async () => {
|
|
301
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
302
|
+
let float = document.querySelector('[data-ask-ai] .float');
|
|
303
|
+
if (!float) { document.querySelector('[data-ask-ai] > button').click(); await sleep(220); float = document.querySelector('[data-ask-ai] .float'); }
|
|
304
|
+
const radios = () => [...document.querySelectorAll('[data-ask-ai] [role="radio"]')];
|
|
305
|
+
radios()[0].click(); await sleep(100);
|
|
306
|
+
radios()[0].focus();
|
|
307
|
+
radios()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
308
|
+
await sleep(150);
|
|
309
|
+
const after = radios().map(r => r.getAttribute('aria-checked'));
|
|
310
|
+
const focused = radios().indexOf(document.activeElement);
|
|
311
|
+
radios()[0].click(); await sleep(100);
|
|
312
|
+
return { after, focused };
|
|
230
313
|
})()`
|
|
231
314
|
|
|
232
315
|
const ASK_ANYWAY = `(async () => {
|
|
@@ -240,30 +323,91 @@ const ASK_ANYWAY = `(async () => {
|
|
|
240
323
|
const after = document.querySelector('[data-ask-ai] .float');
|
|
241
324
|
return {
|
|
242
325
|
role: after ? after.getAttribute('role') : null,
|
|
326
|
+
anchorState: document.querySelector('[data-ask-ai]') ? document.querySelector('[data-ask-ai]').getAttribute('data-ask-ai') : null,
|
|
327
|
+
radios: after ? after.querySelectorAll('[role="radio"]').length : 0,
|
|
243
328
|
items: after ? [...after.querySelectorAll('button')].filter(b => !!b.offsetParent).map(b => (b.textContent || '').trim()) : [],
|
|
244
329
|
};
|
|
245
330
|
})()`
|
|
246
331
|
|
|
247
|
-
|
|
332
|
+
// "SET IT UP" LEAVES A NOTE AND GOES TO THE PAGE THAT SETS A CONNECTOR UP, marked as reached from a report.
|
|
333
|
+
const SET_IT_UP = `(async () => {
|
|
334
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
335
|
+
let float = document.querySelector('[data-ask-ai] .float');
|
|
336
|
+
if (!float) { document.querySelector('[data-ask-ai] > button').click(); await sleep(220); float = document.querySelector('[data-ask-ai] .float'); }
|
|
337
|
+
if (!float) return { fatal: 'no panel open' };
|
|
338
|
+
const btn = [...float.querySelectorAll('button')].find(b => /^Set it up/.test((b.textContent || '').trim()));
|
|
339
|
+
if (!btn) return { fatal: 'no Set it up on the panel' };
|
|
340
|
+
btn.click();
|
|
341
|
+
await sleep(900);
|
|
342
|
+
let stored = null;
|
|
343
|
+
try { stored = localStorage.getItem('cordillera-ask-ai-report'); } catch (e) { stored = 'storage threw: ' + e; }
|
|
344
|
+
return { at: location.pathname + location.search, stored, heading: (document.querySelector('.page-title') || {}).textContent || null };
|
|
345
|
+
})()`
|
|
346
|
+
|
|
347
|
+
// ON CONNECT YOUR AI: the way back, and where pressing it lands.
|
|
348
|
+
const CONTINUE = `(async () => {
|
|
349
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
350
|
+
const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
351
|
+
const pill = document.querySelector('.conn-state');
|
|
352
|
+
const row = document.querySelector('.back-to-report');
|
|
353
|
+
const btn = row ? row.querySelector('button') : null;
|
|
354
|
+
const shown = { pill: pill ? flat(pill.textContent) : null, continueShown: !!btn, label: btn ? flat(btn.textContent) : null,
|
|
355
|
+
line: row && row.querySelector('.back-to-report-line') ? flat(row.querySelector('.back-to-report-line').textContent) : null };
|
|
356
|
+
if (!btn || !window.__pressContinue) return shown;
|
|
357
|
+
btn.click();
|
|
358
|
+
await sleep(1800);
|
|
359
|
+
const anchor = document.querySelector('[data-ask-ai]');
|
|
360
|
+
const float = anchor ? anchor.querySelector('.float') : null;
|
|
361
|
+
return { ...shown, at: location.pathname + location.search, panelOpen: !!float,
|
|
362
|
+
role: float ? float.getAttribute('role') : null, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null,
|
|
363
|
+
radios: float ? float.querySelectorAll('[role="radio"]').length : 0 };
|
|
364
|
+
})()`
|
|
365
|
+
|
|
366
|
+
// A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: the report's header is sticky, and a
|
|
367
|
+
// beyond-viewport capture paints it where the 900px viewport put it. Light, then dark, then back.
|
|
368
|
+
const capture = async (name) => {
|
|
248
369
|
if (!shotsDir) return
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
370
|
+
for (const theme of ['light', 'dark']) {
|
|
371
|
+
await value(`(() => { ${theme === 'dark' ? "document.documentElement.setAttribute('data-theme','dark')" : "document.documentElement.removeAttribute('data-theme')"}; return true })()`)
|
|
372
|
+
await new Promise((r) => setTimeout(r, 350))
|
|
373
|
+
const h = await value('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
|
|
374
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width: WIDTH, height: Math.max(900, h), deviceScaleFactor: 1, mobile: false })
|
|
375
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
376
|
+
const shot = await cmd('Page.captureScreenshot', { format: 'png' })
|
|
377
|
+
await cmd('Emulation.clearDeviceMetricsOverride', {})
|
|
378
|
+
const data = shot.result?.result?.data ?? shot.result?.data
|
|
379
|
+
if (!data) throw new Error(`no screenshot came back for ${name}-${theme}`)
|
|
380
|
+
writeFileSync(join(shotsDir, `${name}-${theme}.png`), Buffer.from(data, 'base64'))
|
|
381
|
+
}
|
|
382
|
+
await value(`(() => { document.documentElement.removeAttribute('data-theme'); return true })()`)
|
|
252
383
|
}
|
|
253
384
|
|
|
254
|
-
const visit = async () => {
|
|
255
|
-
await navigateOrRefuse(cmd, `${origin}
|
|
256
|
-
await new Promise((r) => setTimeout(r,
|
|
385
|
+
const visit = async (path = `/portal/result/${RUN_ID}`) => {
|
|
386
|
+
await navigateOrRefuse(cmd, `${origin}${path}`, { what: 'ask-ai-render-check' })
|
|
387
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
257
388
|
}
|
|
258
389
|
|
|
259
390
|
// ── the drive ───────────────────────────────────────────────────────────────────────────────────────
|
|
260
391
|
|
|
261
|
-
deck = 'connected'; await visit(); const
|
|
262
|
-
|
|
263
|
-
const
|
|
392
|
+
deck = 'connected'; await visit(); const header = await value(HEADER); const connected = await value(PROBE)
|
|
393
|
+
await capture('ask-ai-connected')
|
|
394
|
+
const arrow = await value(ARROW)
|
|
395
|
+
const asked = []
|
|
396
|
+
for (const i of QUESTIONS.keys()) asked.push(await value(ASK(i)))
|
|
264
397
|
|
|
265
|
-
deck = 'neverBefore'; await visit(); const never = await value(PROBE); await
|
|
398
|
+
deck = 'neverBefore'; await visit(); const never = await value(PROBE); await capture('ask-ai-not-connected')
|
|
266
399
|
const anyway = await value(ASK_ANYWAY)
|
|
400
|
+
await visit(); await value(PROBE); const setUp = await value(SET_IT_UP)
|
|
401
|
+
// Reached from a report, not yet connected: the steps are the point, and there is no way back to offer.
|
|
402
|
+
const notYet = await value(CONTINUE)
|
|
403
|
+
|
|
404
|
+
// Connected now. The rail first — nothing to go back to — then the page as "Set it up" left it.
|
|
405
|
+
deck = 'connected'
|
|
406
|
+
await visit('/portal/ai'); const fromRail = await value(CONTINUE)
|
|
407
|
+
await visit('/portal/ai?from=report')
|
|
408
|
+
await value('(() => { window.__pressContinue = true; return true })()')
|
|
409
|
+
const back = await value(CONTINUE)
|
|
410
|
+
const arrived = await value(PROBE)
|
|
267
411
|
|
|
268
412
|
deck = 'cannotTell'; await visit(); const unknown = await value(PROBE)
|
|
269
413
|
deck = 'noConnector'; await visit(); const none = await value(PROBE)
|
|
@@ -283,58 +427,96 @@ const show = (o) => JSON.stringify(o)
|
|
|
283
427
|
|
|
284
428
|
// A FLOOR FIRST. Every assertion below reads a field off one of these objects, and a deck that failed to
|
|
285
429
|
// render returns null — which would make `!x.hasAddress` true and half of this file pass on a blank page.
|
|
286
|
-
for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown],
|
|
287
|
-
|
|
430
|
+
for (const [name, r] of [['header', header], ['connected', connected], ['never connected', never], ['could not tell', unknown],
|
|
431
|
+
['no connector', none], ['local only', local], ['set it up', setUp], ['continue', back], ['arrival', arrived]]) {
|
|
432
|
+
ok(r && typeof r === 'object', `the ${name} step measured nothing at all — the screen did not render`)
|
|
288
433
|
}
|
|
289
434
|
if (fail.length) { for (const f of fail) console.error(` ✗ ${f}`); console.error('ask-ai-render-check: the drive did not reach the screen'); process.exit(1) }
|
|
290
435
|
|
|
291
|
-
// ──
|
|
436
|
+
// ── the header above the report ──
|
|
437
|
+
ok(show(header.crumbs) === show(['All Clearances', '›', MARK]), `the breadcrumb reads ${show(header.crumbs)}`)
|
|
438
|
+
ok(header.mark === MARK, `the header names ${show(header.mark)}, not the mark`)
|
|
439
|
+
const LINE = `${NAME} · ${PRODUCT_NAME} · searched ${ISO_DATE} · issued ${ISSUED_DAY}`
|
|
440
|
+
ok(header.line === LINE, `the header line reads ${show(header.line)}, not ${show(LINE)} — the issued day is Zurich's`)
|
|
441
|
+
ok(show(header.actions) === show(['Ask AI', 'Export']), `the header's actions are ${show(header.actions)}, not Ask AI then Export`)
|
|
442
|
+
ok(header.actionKinds.length === 2 && header.actionKinds.every((c) => /\bbtn-ghost\b/.test(c)),
|
|
443
|
+
`the two actions are not both secondary buttons: ${show(header.actionKinds)}`)
|
|
444
|
+
ok(header.topbar === '', `the top bar names ${show(header.topbar)} over a report, which is not company-scoped`)
|
|
445
|
+
ok(!header.followUp, '"Ask a follow-up" is on the screen')
|
|
446
|
+
|
|
447
|
+
// ── connected: the panel ──
|
|
292
448
|
ok(connected.drawn && connected.opened, `connected: the button did not open anything — ${show(connected)}`)
|
|
293
|
-
ok(connected.
|
|
294
|
-
|
|
295
|
-
ok(connected.
|
|
296
|
-
ok(connected.
|
|
297
|
-
|
|
298
|
-
ok(connected.
|
|
299
|
-
ok(connected.
|
|
300
|
-
ok(connected.
|
|
301
|
-
|
|
302
|
-
|
|
449
|
+
ok(connected.buttonText === 'Ask AI' && connected.buttonAria === null,
|
|
450
|
+
`connected: the button reads ${show(connected.buttonText)} with an accessible name of ${show(connected.buttonAria)}`)
|
|
451
|
+
ok(connected.role === 'dialog' && connected.label === 'Ask AI', `connected: the panel is role="${connected.role}" named ${show(connected.label)}`)
|
|
452
|
+
ok(connected.anchorState === 'ask', `connected: the control reports state "${connected.anchorState}"`)
|
|
453
|
+
ok(connected.head === `${MARK} · ${PRODUCT_NAME} · searched ${ISO_DATE}`, `connected: the panel's first line reads ${show(connected.head)}`)
|
|
454
|
+
ok(connected.group === 'What to ask', `connected: the questions are not a named radio group — ${show(connected.group)}`)
|
|
455
|
+
ok(show(connected.radios.map((r) => r.text)) === show(QUESTIONS.map((q) => q.label)), `connected: the questions read ${show(connected.radios)}`)
|
|
456
|
+
ok(show(connected.radios.map((r) => r.checked)) === show(['true', 'false', 'false', 'false']), `connected: the first question is not the one selected — ${show(connected.radios)}`)
|
|
457
|
+
ok(connected.primary === 'Open in Claude', `connected: the button reads ${show(connected.primary)}`)
|
|
458
|
+
ok(connected.note === 'The question is typed into your assistant; press send there.', `connected: the quiet line reads ${show(connected.note)}`)
|
|
459
|
+
ok(connected.rightAligned && connected.below, `connected: the panel does not hang under the button with right edges aligned — ${show(connected)}`)
|
|
460
|
+
// THE DESIGN IS 360. Measured, so a panel squeezed to the old menu's width fails here rather than wrapping.
|
|
461
|
+
ok(connected.width >= 340 && connected.width <= 380, `connected: the panel is ${connected.width}px wide; the design is 360`)
|
|
462
|
+
ok(show(arrow.after) === show(['false', 'true', 'false', 'false']) && arrow.focused === 1,
|
|
463
|
+
`connected: the arrow key did not move the choice to the next question — ${show(arrow)}`)
|
|
464
|
+
|
|
465
|
+
// ── connected: what a press actually opens, for each question ──
|
|
303
466
|
//
|
|
304
|
-
// THE QUESTION HAS TO BE IN THE LINK, not merely in the source. This is the assertion the
|
|
305
|
-
// is for: a press that opened claude.ai with
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
ok(
|
|
313
|
-
ok(
|
|
467
|
+
// THE SELECTED QUESTION HAS TO BE IN THE LINK, not merely in the source. This is the assertion the change
|
|
468
|
+
// is for: a press that opened claude.ai with the first question whatever was chosen would look identical
|
|
469
|
+
// on screen.
|
|
470
|
+
for (const [i, press] of asked.entries()) {
|
|
471
|
+
const href = (press?.opened ?? []).at(-1) ?? ''
|
|
472
|
+
const u = (() => { try { return new URL(href) } catch { return null } })()
|
|
473
|
+
ok(!press?.fatal, `question ${i + 1}: ${press?.fatal}`)
|
|
474
|
+
ok(press?.checked?.[i] === 'true', `question ${i + 1} was not the one selected when pressed — ${show(press?.checked)}`)
|
|
475
|
+
ok(u && u.origin + u.pathname === 'https://claude.ai/new', `question ${i + 1} opened ${show(href)}`)
|
|
476
|
+
ok(u && show([...u.searchParams.keys()]) === show(['q']), `question ${i + 1}'s link carries more than the text to type in: ${show(href)}`)
|
|
477
|
+
ok(u?.searchParams.get('q') === QUESTIONS[i].typed, `question ${i + 1} typed ${show(u?.searchParams.get('q'))}, not ${show(QUESTIONS[i].typed)}`)
|
|
478
|
+
ok(!/noref|tmpa-|run /i.test(u?.searchParams.get('q') ?? ''), `question ${i + 1} carries a run identifier`)
|
|
479
|
+
ok(press?.closed === true, `question ${i + 1}: the panel stayed open after it opened the assistant`)
|
|
314
480
|
}
|
|
315
481
|
|
|
316
|
-
// ── never connected: the panel ──
|
|
482
|
+
// ── never connected: the connect panel, unchanged ──
|
|
317
483
|
ok(never.drawn && never.opened, `never connected: the button did not open anything — ${show(never)}`)
|
|
318
|
-
ok(never.role === 'dialog', `never connected: the float is role="${never.role}"
|
|
484
|
+
ok(never.role === 'dialog' && never.label === 'Connect your AI first', `never connected: the float is role="${never.role}" named ${show(never.label)}`)
|
|
319
485
|
ok(never.anchorState === 'connect', `never connected: the control reports state "${never.anchorState}"`)
|
|
320
486
|
ok(/Connect your AI first/.test(never.text), `never connected: the panel does not say what it is — ${show(never.text)}`)
|
|
321
487
|
ok(/Connect Claude or ChatGPT once/.test(never.text), 'never connected: the explaining line is missing')
|
|
322
488
|
ok(never.items.some((t) => /^Set it up/.test(t)), `never connected: no Set it up — ${show(never.items)}`)
|
|
323
489
|
ok(never.items.some((t) => /Already connected\? Ask anyway/.test(t)), `never connected: no way past it — ${show(never.items)}`)
|
|
324
|
-
ok(
|
|
325
|
-
`never connected: the
|
|
490
|
+
ok(never.radios.length === 0 && never.primary !== 'Open in Claude',
|
|
491
|
+
`never connected: the questions are on the panel, so the setup step is decoration — ${show(never.items)}`)
|
|
326
492
|
ok(never.width >= 240 && never.width <= 320, `never connected: the panel is ${never.width}px wide; the design is 276`)
|
|
327
493
|
|
|
328
494
|
// ── the way past a measurement that is behind ──
|
|
329
|
-
ok(anyway?.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
495
|
+
ok(anyway?.anchorState === 'ask' && anyway?.radios === 4, `"Ask anyway" did not reach the questions — ${show(anyway)}`)
|
|
496
|
+
|
|
497
|
+
// ── Set it up: the note, and the page it opens ──
|
|
498
|
+
ok(setUp.at === '/portal/ai?from=report', `Set it up went to ${show(setUp.at)}, not the setup page marked as reached from a report`)
|
|
499
|
+
const note = (() => { try { return JSON.parse(setUp.stored ?? '') } catch { return null } })()
|
|
500
|
+
ok(note?.runId === RUN_ID && note?.mark === MARK && note?.markSlug === null, `Set it up left ${show(setUp.stored)} as its note`)
|
|
501
|
+
ok(setUp.heading === 'Connect your AI', `Set it up opened a page headed ${show(setUp.heading)}`)
|
|
502
|
+
ok(notYet && notYet.continueShown === false && notYet.pill === 'Not connected yet',
|
|
503
|
+
`reached from a report but not connected, the page offers the way back anyway — ${show(notYet)}`)
|
|
504
|
+
|
|
505
|
+
// ── Continue: from the rail there is nothing to go back to; from the report, it lands with the panel open ──
|
|
506
|
+
ok(fromRail && fromRail.pill === 'Connected' && fromRail.continueShown === false, `reached from the rail, the page offers a way back — ${show(fromRail)}`)
|
|
507
|
+
ok(back.continueShown && back.label === `Continue with ${MARK}`, `connected and reached from a report, the page offers ${show(back.label)}`)
|
|
508
|
+
ok(back.line === 'Back to the report you were reading.', `the line beside Continue reads ${show(back.line)}`)
|
|
509
|
+
ok(back.at === `/portal/result/${RUN_ID}`, `Continue landed on ${show(back.at)} — not the report, or with the signal still in the address`)
|
|
510
|
+
ok(back.panelOpen && back.role === 'dialog' && back.anchorState === 'ask' && back.radios === 4,
|
|
511
|
+
`Continue did not land with the panel open in its connected form — ${show(back)}`)
|
|
512
|
+
ok(arrived.openOnArrival === true, `the panel was not already open when the report arrived — ${show(arrived)}`)
|
|
513
|
+
|
|
514
|
+
// ── could not tell: the third state renders, and renders as the connect panel ──
|
|
333
515
|
//
|
|
334
516
|
// DRIVEN AS ITS OWN DECK. It reaching the same screen as `false` is a decision about which way to be
|
|
335
517
|
// wrong, not an identity — and a build that threw on a null would fail here and nowhere else.
|
|
336
518
|
ok(unknown.drawn && unknown.opened, `could not tell: nothing opened — ${show(unknown)}`)
|
|
337
|
-
ok(unknown.
|
|
519
|
+
ok(unknown.anchorState === 'connect', `could not tell: a null was rendered as "${unknown.anchorState}", so an unmeasured reader was handed the questions`)
|
|
338
520
|
ok(/Connect your AI first/.test(unknown.text), 'could not tell: the panel did not draw')
|
|
339
521
|
|
|
340
522
|
// ── no connector: nothing is drawn ──
|
|
@@ -342,7 +524,7 @@ ok(none.drawn === false, `no connector: a button was drawn that can do nothing
|
|
|
342
524
|
|
|
343
525
|
// ── a laptop install: a local route IS a connector ──
|
|
344
526
|
ok(local.drawn && local.opened, `local only: the case the old panel stranded is stranded again — ${show(local)}`)
|
|
345
|
-
ok(local.
|
|
527
|
+
ok(local.anchorState === 'ask' && local.radios.length === 4, `local only: ${show(local.anchorState)} with ${local.radios.length} question(s)`)
|
|
346
528
|
|
|
347
529
|
// ── NO ADDRESS AND NO COPY LINK, in any state that draws ──
|
|
348
530
|
for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['local only', local]]) {
|
|
@@ -355,5 +537,5 @@ if (fail.length) {
|
|
|
355
537
|
console.error(`ask-ai-render-check: ${fail.length} thing(s) a reader would meet`)
|
|
356
538
|
process.exit(1)
|
|
357
539
|
}
|
|
358
|
-
console.log(`ask-ai-render-check: the
|
|
540
|
+
console.log(`ask-ai-render-check: the header, both panels and the unmeasured state render; all four questions reach the link typed in; Set it up leaves its note and Continue lands on the report with the panel open; no button where there is no connector`)
|
|
359
541
|
if (shotsDir) console.log(`ask-ai-render-check: shots in ${shotsDir}`)
|
|
@@ -153,8 +153,22 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
|
|
|
153
153
|
export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
|
|
154
154
|
// `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
|
|
155
155
|
// reading of every changed path, which is what a pure table of paths means.
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// MATCHED ON THE NAME, NOT THE PATH, BECAUSE A PRE-RELEASE MOVES A NOTE IT CONSUMES.
|
|
157
|
+
//
|
|
158
|
+
// changesets in pre mode does not delete the note it publishes: it MOVES it from
|
|
159
|
+
// `.changeset/<name>.md` into `.changeset/pre/<name>.md`, and re-applies every one of them when the
|
|
160
|
+
// pre range is exited. The note still exists and still reaches the releases page — twice, once in the
|
|
161
|
+
// pre-release and once in the stable cut it rolls into.
|
|
162
|
+
//
|
|
163
|
+
// Asking whether the ADDED PATH still exists therefore loses every note written before the last
|
|
164
|
+
// pre-release cut, and the commit that wrote one is reported as owing a note it did write. The
|
|
165
|
+
// failure arrives the moment a branch merges a main that has had a beta cut on it, which is now the
|
|
166
|
+
// ordinary case rather than an edge one.
|
|
167
|
+
const presentNames = atHead
|
|
168
|
+
? new Set(atHead.filter(isNotePath).map((p) => p.split("/").pop()))
|
|
169
|
+
: null;
|
|
170
|
+
const notesOf = (c) => (c.added ?? c.paths)
|
|
171
|
+
.filter((p) => isNotePath(p) && (!presentNames || presentNames.has(p.split("/").pop())));
|
|
158
172
|
const notes = [...new Set(commits.flatMap(notesOf))];
|
|
159
173
|
const noteNames = new Set(notes.map((p) => p.split("/").pop()));
|
|
160
174
|
const visible = [...new Set(commits.flatMap((c) => shipped(c.paths, files)))];
|