clearotron 0.3.2-beta.2 → 0.3.2-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +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 +36 -0
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +34 -4
- package/driver/suite-census.json +114 -48
- package/mcp-server/CHANGELOG.md +8 -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-5UyqAyNM.js} +3384 -2973
- package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-CVOIvdhc.css} +992 -205
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/script-form.mjs +2 -2
- package/providers/clarivate/src/core.js +18 -27
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/core.js +3 -3
- package/scripts/ask-ai-render-check.mjs +287 -105
- package/scripts/env-classify.mjs +6 -0
- 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
|
@@ -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)))];
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// settings-render-check.mjs — Installation settings, Preferences, About and the sign-in page, drawn in a
|
|
5
|
+
// real browser, in both themes.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/settings-render-check.mjs [--shot-dir <dir>] [--quiet]
|
|
8
|
+
//
|
|
9
|
+
// WHY A BROWSER. The tests beside these screens read their source and drive their contract, and neither
|
|
10
|
+
// can see what this can: that no variable name, credential path or README reference reaches the text a
|
|
11
|
+
// reader sees; that a blur turned on survives a reload with its button still pressed, including in a
|
|
12
|
+
// browser whose storage refuses; that the administrator's help on the sign-in card really is folded shut;
|
|
13
|
+
// and that a long reset command stays inside the card.
|
|
14
|
+
//
|
|
15
|
+
// THE FIXTURES HAVE ONE AUTHOR. The settings rows are the driver's own inventory run against a fixture
|
|
16
|
+
// environment, the sign-in page is `loginPage` itself, and About's facts are `productIdentity`. A
|
|
17
|
+
// hand-written row would carry only the detail its author remembered; these carry the detail the product
|
|
18
|
+
// writes, so "none of it reaches the page" is asserted against the real thing.
|
|
19
|
+
//
|
|
20
|
+
// With `--shot-dir`, one full-height PNG per state, in light and dark, at 1280 wide. `--quiet` leaves out
|
|
21
|
+
// the closing dump of everything the probes read.
|
|
22
|
+
|
|
23
|
+
import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
|
|
24
|
+
import { createServer } from 'node:http'
|
|
25
|
+
import { reapOnExit } from '../shared/reap-on-exit.mjs' // — a detached group dies with this script
|
|
26
|
+
import { readFileSync, existsSync, rmSync, writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
|
|
27
|
+
import { join, extname, dirname } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import { spawn } from 'node:child_process'
|
|
30
|
+
import { engineInventory, providerInventory } from '../driver/config-inventory.mjs'
|
|
31
|
+
import { authView } from '../driver/portal-config-view.mjs'
|
|
32
|
+
import { loginPage, LOGIN_IN_FRONT_DOC } from '../driver/portal-service.mjs'
|
|
33
|
+
import { productIdentity } from '../shared/product-identity.mjs'
|
|
34
|
+
import { browserRun } from '../shared/browser-temp-root.mjs'
|
|
35
|
+
|
|
36
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
37
|
+
const DIST = join(REPO, 'portal-ui', 'dist')
|
|
38
|
+
if (!existsSync(join(DIST, 'index.html'))) {
|
|
39
|
+
console.error('settings-render-check: portal-ui/dist is not built. Run `npm run build:ui` first.')
|
|
40
|
+
process.exit(2)
|
|
41
|
+
}
|
|
42
|
+
const shotDir = process.argv.includes('--shot-dir') ? process.argv[process.argv.indexOf('--shot-dir') + 1] : null
|
|
43
|
+
if (shotDir) mkdirSync(shotDir, { recursive: true })
|
|
44
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
|
|
45
|
+
|
|
46
|
+
// ── the fixtures ─────────────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const EMAIL = 'dana@northwind.example'
|
|
49
|
+
const CONTACT = 'mailto:it@northwind.example'
|
|
50
|
+
const ISSUER = 'https://login.example-firm.test/'
|
|
51
|
+
const LONG_RESET = 'npx clearotron@0.3.0-beta.5 passphrase --reset --base $HOME/trademark-demo'
|
|
52
|
+
const BLUR_KEY = 'cordillera-blur-names'
|
|
53
|
+
|
|
54
|
+
// The deployment the board draws: a register and the research key set, the open-web key not, no case-law
|
|
55
|
+
// sign-in, the engine on a subscription. The values are inert fixture words and never leave this process:
|
|
56
|
+
// the inventory records names and states, never a value.
|
|
57
|
+
const credsDir = mkdtempSync(join(process.env.TMPDIR || '/tmp', 'settings-creds-'))
|
|
58
|
+
const deployment = {
|
|
59
|
+
CLEAROTRON_DATABASE: 'corsearch', CORSEARCH_SESSION_KEY: 'fixture-not-a-key', PERPLEXITY_API_KEY: 'fixture-not-a-key',
|
|
60
|
+
OAUTH_BRIDGE_CREDS_DIR: credsDir, PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '',
|
|
61
|
+
}
|
|
62
|
+
const adminConfig = {
|
|
63
|
+
available: true, note: null, source: 'live', lastRun: null, built: null, flags: [],
|
|
64
|
+
// The program is PRESENT here whatever this machine has, because the board's deployment runs searches;
|
|
65
|
+
// the engine row's red states belong to the engine-state tests, not to this page's layout.
|
|
66
|
+
engine: { ...engineInventory(deployment), binaryPresent: true, program: 'claude', install: 'npm install -g @anthropic-ai/claude-code' },
|
|
67
|
+
providers: providerInventory(deployment),
|
|
68
|
+
auth: authView({ mode: 'auth-proxy', oidcIssuer: ISSUER }),
|
|
69
|
+
}
|
|
70
|
+
const about = productIdentity()
|
|
71
|
+
|
|
72
|
+
const state = { contact: CONTACT, reset: null }
|
|
73
|
+
// THE PAGE'S OWN LOADS, COUNTED. A screen that asks the server twice paints exactly like one that asks once,
|
|
74
|
+
// and each request spends the person's rate budget, so the only instrument that sees a double load is a count.
|
|
75
|
+
const loads = []
|
|
76
|
+
|
|
77
|
+
const server = createServer((req, res) => {
|
|
78
|
+
const path = new URL(req.url, 'http://localhost').pathname
|
|
79
|
+
const json = (o) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)) }
|
|
80
|
+
// THE ADMIN ADDRESSES ARE BOTH A SCREEN AND ITS DATA, decided by what the caller asked for — the rule
|
|
81
|
+
// driver/portal-static.mjs applies: the app's own fetches ask for JSON, a browser navigation for HTML.
|
|
82
|
+
const wantsPage = String(req.headers.accept ?? '').includes('text/html')
|
|
83
|
+
if (!wantsPage && (path === '/portal/admin/config' || path === '/portal/api/about')) loads.push(path)
|
|
84
|
+
if (path === '/portal/api/me') {
|
|
85
|
+
return json({ permissions: { run: true, manage: true }, email: EMAIL, accounts: '*',
|
|
86
|
+
access: [{ kind: 'everything' }], organisations: [{ key: 'northwind', name: 'Northwind Group' }],
|
|
87
|
+
accountNames: { northwind: 'Northwind Foods' }, brand: 'Northwind Group', administratorContact: state.contact })
|
|
88
|
+
}
|
|
89
|
+
if (path === '/portal/admin/config' && !wantsPage) return json(adminConfig)
|
|
90
|
+
if (path === '/portal/api/about') return json(about)
|
|
91
|
+
if (path === '/portal/admin/roster' && !wantsPage) return json({ customers: [{ key: 'northwind', name: 'Northwind Foods' }] })
|
|
92
|
+
if (path === '/portal/login') {
|
|
93
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
94
|
+
return res.end(loginPage({ email: EMAIL, resetCommand: state.reset }))
|
|
95
|
+
}
|
|
96
|
+
if (path.startsWith('/portal/api/') || (path.startsWith('/portal/admin/') && !wantsPage)) return json({})
|
|
97
|
+
const file = path === '/' || (path.startsWith('/portal') && !path.includes('.')) ? '/index.html' : path.replace(/^\/portal/, '')
|
|
98
|
+
const full = join(DIST, file)
|
|
99
|
+
if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
|
|
100
|
+
res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
|
|
101
|
+
res.end(readFileSync(full))
|
|
102
|
+
})
|
|
103
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
104
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
105
|
+
|
|
106
|
+
// ── the browser ──────────────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
const { profile: userDir, env: chromeEnv } = browserRun('settings-check-')
|
|
109
|
+
const chrome = spawn('google-chrome', [
|
|
110
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
111
|
+
`--user-data-dir=${userDir}`, '--window-size=1280,900', '--remote-debugging-port=0', 'about:blank',
|
|
112
|
+
], { stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: chromeEnv })
|
|
113
|
+
reapOnExit(chrome)
|
|
114
|
+
|
|
115
|
+
let devtools = ''
|
|
116
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
117
|
+
const t = setTimeout(() => reject(new Error(`chrome did not report a devtools endpoint within 60s. Its stderr was:\n${devtools || '(nothing)'}`)), 60000)
|
|
118
|
+
chrome.stderr.on('data', (c) => {
|
|
119
|
+
devtools += c
|
|
120
|
+
const m = devtools.match(/ws:\/\/[^\s]+/)
|
|
121
|
+
if (m) { clearTimeout(t); resolve(m[0]) }
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
const ws = new WebSocket(wsUrl)
|
|
125
|
+
let id = 0
|
|
126
|
+
const pending = new Map()
|
|
127
|
+
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
|
|
128
|
+
await new Promise((r) => ws.addEventListener('open', r))
|
|
129
|
+
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
130
|
+
const { result: targets } = await send('Target.getTargets')
|
|
131
|
+
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
132
|
+
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
133
|
+
const sessionId = sess.sessionId
|
|
134
|
+
const cmd = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
135
|
+
await cmd('Page.enable')
|
|
136
|
+
// A probe that throws says so, rather than returning nothing for the assertions after it to misread.
|
|
137
|
+
const evalIn = async (expr) => {
|
|
138
|
+
const r = (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result
|
|
139
|
+
if (r?.exceptionDetails) console.error(` ! a probe threw: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`)
|
|
140
|
+
return r?.result?.value
|
|
141
|
+
}
|
|
142
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
143
|
+
|
|
144
|
+
/** Wait for a condition in the page, and say so when it never came, rather than measuring a half-drawn page. */
|
|
145
|
+
async function settle(condition, what, ms = 8000) {
|
|
146
|
+
const until = Date.now() + ms
|
|
147
|
+
while (Date.now() < until) {
|
|
148
|
+
if (await evalIn(`(() => { try { return !!(${condition}) } catch { return false } })()`)) return true
|
|
149
|
+
await sleep(100)
|
|
150
|
+
}
|
|
151
|
+
failures++
|
|
152
|
+
console.error(` ✗ ${what} — never happened`)
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function open(path, ready, what) {
|
|
157
|
+
await cmd('Page.navigate', { url: 'about:blank' })
|
|
158
|
+
await sleep(100)
|
|
159
|
+
await navigateOrRefuse(cmd, `${origin}${path}`, { what: 'settings-render-check' })
|
|
160
|
+
return settle(ready, what)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A RELOAD IS NOT DONE WHEN IT IS ASKED FOR. `Page.reload` returns while the old document is still up, and
|
|
164
|
+
// a condition read then is read off the page being replaced. The old document is marked first, so the wait
|
|
165
|
+
// is for a new document that is ready rather than for an old one that already was.
|
|
166
|
+
async function reload(ready, what) {
|
|
167
|
+
await evalIn('window.__beforeReload = true')
|
|
168
|
+
await cmd('Page.reload', {})
|
|
169
|
+
return settle(`!window.__beforeReload && (${ready})`, what)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function setTheme(theme) {
|
|
173
|
+
await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)}); try { localStorage.setItem('cordillera-theme', ${JSON.stringify(theme)}) } catch {} return true })()`)
|
|
174
|
+
await sleep(250)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: a beyond-viewport capture paints the sticky rail
|
|
178
|
+
// and top bar where the 900px viewport put them.
|
|
179
|
+
async function capture(name) {
|
|
180
|
+
if (!shotDir) return
|
|
181
|
+
await evalIn('window.scrollTo(0, 0)')
|
|
182
|
+
const h = await evalIn('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
|
|
183
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width: 1280, height: Math.max(900, h), deviceScaleFactor: 1, mobile: false })
|
|
184
|
+
await sleep(400)
|
|
185
|
+
const shot = await cmd('Page.captureScreenshot', { format: 'png' })
|
|
186
|
+
await cmd('Emulation.clearDeviceMetricsOverride', {})
|
|
187
|
+
const data = shot.result?.data
|
|
188
|
+
if (!data) throw new Error(`no screenshot came back for ${name}`)
|
|
189
|
+
writeFileSync(join(shotDir, `${name}.png`), Buffer.from(data, 'base64'))
|
|
190
|
+
shots.push(`${name}.png`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A real press, with the mouse, where the reader would press. */
|
|
194
|
+
async function press(selector) {
|
|
195
|
+
const at = await evalIn(`(() => {
|
|
196
|
+
const el = document.querySelector(${JSON.stringify(selector)})
|
|
197
|
+
if (!el) return null
|
|
198
|
+
el.scrollIntoView({ block: 'center' })
|
|
199
|
+
const b = el.getBoundingClientRect()
|
|
200
|
+
return { x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2) }
|
|
201
|
+
})()`)
|
|
202
|
+
if (!at) return false
|
|
203
|
+
for (const type of ['mousePressed', 'mouseReleased']) {
|
|
204
|
+
await cmd('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 })
|
|
205
|
+
}
|
|
206
|
+
await sleep(250)
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let failures = 0
|
|
211
|
+
const shots = []
|
|
212
|
+
const ok = (cond, msg) => { if (!cond) { failures++; console.error(` ✗ ${msg}`) } else console.log(` ✓ ${msg}`) }
|
|
213
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
214
|
+
|
|
215
|
+
// What a reader must never see on Installation settings. Scanned over the text the page draws, so the
|
|
216
|
+
// rows the server sent — which DO carry this detail — are what is being tested.
|
|
217
|
+
const LEAKS = `[/\\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\\b/, /(?<![:\\w\\/.])(?:~|\\.{1,2})?\\/[\\w.-]+\\/[\\w.\\/-]+|\\.json\\b/, /README|\\.md\\b/i]`
|
|
218
|
+
|
|
219
|
+
// ── Installation settings ────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const settingsProbe = `(() => {
|
|
222
|
+
const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
|
|
223
|
+
const screen = document.querySelector('.screen')
|
|
224
|
+
const groups = [...screen.querySelectorAll('.cfg-group')]
|
|
225
|
+
const row = (r) => ({
|
|
226
|
+
name: flat(r.querySelector('.cfg-name')?.textContent), val: flat(r.querySelector('.cfg-val')?.textContent) || null,
|
|
227
|
+
state: flat(r.querySelector('.cfg-state')?.textContent),
|
|
228
|
+
notes: [...r.querySelectorAll('.cfg-note')].map((n) => flat(n.textContent)),
|
|
229
|
+
mono: [...r.querySelectorAll('.cfg-note.mono')].map((n) => flat(n.textContent)),
|
|
230
|
+
guide: r.querySelector('.cfg-act a.pill') ? { text: flat(r.querySelector('.cfg-act a.pill').textContent), href: r.querySelector('.cfg-act a.pill').getAttribute('href'), target: r.querySelector('.cfg-act a.pill').getAttribute('target') } : null,
|
|
231
|
+
stateRight: Math.round(r.querySelector('.cfg-state').getBoundingClientRect().right),
|
|
232
|
+
})
|
|
233
|
+
const pageText = document.body.innerText
|
|
234
|
+
return {
|
|
235
|
+
renderedChars: flat(screen.innerText).length,
|
|
236
|
+
title: flat(screen.querySelector('h1.page-title')?.textContent),
|
|
237
|
+
sections: groups.map((g) => flat(g.querySelector(':scope > .eyebrow')?.textContent)),
|
|
238
|
+
signIn: [...(groups[0]?.querySelectorAll('.cfg-row') ?? [])].map(row),
|
|
239
|
+
engine: [...(groups[1]?.querySelectorAll('.cfg-row') ?? [])].map(row),
|
|
240
|
+
categories: [...(groups[2]?.querySelectorAll('.cfg-cat') ?? [])].map((c) => ({
|
|
241
|
+
label: flat(c.querySelector('.cfg-sub')?.textContent),
|
|
242
|
+
note: flat(c.querySelector('.cfg-cat-note')?.textContent) || null,
|
|
243
|
+
rows: [...c.querySelectorAll('.cfg-row')].map(row),
|
|
244
|
+
})),
|
|
245
|
+
leaks: ${LEAKS}.flatMap((re) => { const m = re.exec(pageText); return m ? [m[0]] : [] }),
|
|
246
|
+
}
|
|
247
|
+
})()`
|
|
248
|
+
|
|
249
|
+
const menuProbe = `(async () => {
|
|
250
|
+
const avatar = document.querySelector('button[aria-label="Settings and about"]')
|
|
251
|
+
if (!avatar) return null
|
|
252
|
+
avatar.click()
|
|
253
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
254
|
+
const items = [...document.querySelectorAll('[role="menu"] [role="menuitem"]')].map((b) => b.textContent.trim())
|
|
255
|
+
avatar.click()
|
|
256
|
+
return items
|
|
257
|
+
})()`
|
|
258
|
+
|
|
259
|
+
// ── Preferences ──────────────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const preferencesProbe = `(() => {
|
|
262
|
+
const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
|
|
263
|
+
const screen = document.querySelector('.screen')
|
|
264
|
+
const link = screen.querySelector('.pref-line a')
|
|
265
|
+
const eye = screen.querySelector('.pref-blur button')
|
|
266
|
+
const topEye = document.querySelector('header.topbar button[aria-label="Blur names for screen sharing"]')
|
|
267
|
+
const address = screen.querySelector('.pref-dl dd[data-anon="mark"]')
|
|
268
|
+
let stored = null
|
|
269
|
+
try { stored = localStorage.getItem('${BLUR_KEY}') } catch { stored = 'refused' }
|
|
270
|
+
return {
|
|
271
|
+
renderedChars: flat(screen.innerText).length,
|
|
272
|
+
title: flat(screen.querySelector('h1.page-title')?.textContent),
|
|
273
|
+
cards: [...screen.querySelectorAll('.pref-card > .pref-title')].map((h) => flat(h.textContent)),
|
|
274
|
+
rows: [...screen.querySelectorAll('.pref-dl dt')].map((d) => flat(d.textContent) + ': ' + flat(d.nextElementSibling?.textContent)),
|
|
275
|
+
lines: [...screen.querySelectorAll('.pref-line')].map((p) => flat(p.textContent)),
|
|
276
|
+
link: link ? { text: flat(link.textContent), href: link.getAttribute('href') } : null,
|
|
277
|
+
logOut: screen.querySelector('a.pill[href="/portal/sign-out"]') ? flat(screen.querySelector('a.pill[href="/portal/sign-out"]').textContent) : null,
|
|
278
|
+
blurLine: flat(screen.querySelector('.pref-blur-line')?.textContent),
|
|
279
|
+
eye: eye ? { label: eye.getAttribute('aria-label'), pressed: eye.getAttribute('aria-pressed'), text: flat(eye.textContent), cls: eye.className } : null,
|
|
280
|
+
topEye: topEye ? { pressed: topEye.getAttribute('aria-pressed'), cls: topEye.className, icon: topEye.innerHTML === eye?.innerHTML } : null,
|
|
281
|
+
blurredDocument: document.documentElement.classList.contains('anon-on'),
|
|
282
|
+
addressFilter: address ? getComputedStyle(address).filter : null,
|
|
283
|
+
// EVERY PLACE THE COMPANY'S NAME IS DRAWN, anywhere on the page — the rail and the top bar as well as
|
|
284
|
+
// the screen — that nothing marks for the blur. "It covers every mark and company on screen" is a
|
|
285
|
+
// promise about the whole page, and the rail's switcher once broke it with nothing measuring it.
|
|
286
|
+
unmarkedCompany: (() => {
|
|
287
|
+
const out = []
|
|
288
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
|
|
289
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
290
|
+
if (!n.textContent.includes('Northwind Foods')) continue
|
|
291
|
+
const el = n.parentElement
|
|
292
|
+
if (!el || el.closest('[data-anon="mark"]')) continue
|
|
293
|
+
out.push((el.tagName || '?').toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''))
|
|
294
|
+
}
|
|
295
|
+
return out
|
|
296
|
+
})(),
|
|
297
|
+
companyDrawn: document.body.innerText.includes('Northwind Foods') || [...document.querySelectorAll('option')].some((o) => o.textContent.includes('Northwind Foods')),
|
|
298
|
+
stored,
|
|
299
|
+
notKept: /not kept|starts switched off|until you reload|every time you open|These two settings stay/i.test(screen.innerText),
|
|
300
|
+
theme: document.documentElement.getAttribute('data-theme'),
|
|
301
|
+
}
|
|
302
|
+
})()`
|
|
303
|
+
|
|
304
|
+
// ── About ────────────────────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
const aboutProbe = `(() => {
|
|
307
|
+
const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
|
|
308
|
+
const screen = document.querySelector('.screen')
|
|
309
|
+
const dts = [...screen.querySelectorAll('.about-dl dt')]
|
|
310
|
+
const dd = (label) => dts.find((d) => flat(d.textContent) === label)?.nextElementSibling
|
|
311
|
+
return {
|
|
312
|
+
renderedChars: flat(screen.innerText).length,
|
|
313
|
+
title: flat(screen.querySelector('h1.page-title')?.textContent),
|
|
314
|
+
rows: dts.map((d) => flat(d.textContent)),
|
|
315
|
+
build: flat(dd('Build')?.querySelector('.mono')?.textContent) || null,
|
|
316
|
+
buildFont: dd('Build')?.querySelector('.mono') ? getComputedStyle(dd('Build').querySelector('.mono')).fontFamily : null,
|
|
317
|
+
source: dd('Source')?.querySelector('a') ? { text: flat(dd('Source').querySelector('a').textContent), href: dd('Source').querySelector('a').getAttribute('href'), lines: dd('Source').querySelector('a').getClientRects().length } : null,
|
|
318
|
+
fullText: dd('Licence')?.querySelector('a.pill') ? { text: flat(dd('Licence').querySelector('a.pill').textContent), href: dd('Licence').querySelector('a.pill').getAttribute('href') } : null,
|
|
319
|
+
copyright: flat(dd('Copyright')?.textContent),
|
|
320
|
+
pills: [...screen.querySelectorAll('.about-docs a.pill')].map((a) => flat(a.textContent)),
|
|
321
|
+
}
|
|
322
|
+
})()`
|
|
323
|
+
|
|
324
|
+
// ── Sign in ──────────────────────────────────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
const signInProbe = `(() => {
|
|
327
|
+
const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
|
|
328
|
+
const card = document.querySelector('.card')
|
|
329
|
+
const fold = card?.querySelector('details.fold')
|
|
330
|
+
const hints = fold ? [...fold.querySelectorAll('.hint')] : []
|
|
331
|
+
const code = fold?.querySelector('code')
|
|
332
|
+
const cs = card ? getComputedStyle(card) : null
|
|
333
|
+
const inner = card ? (() => { const b = card.getBoundingClientRect(); return { left: b.left + parseFloat(cs.paddingLeft), right: b.right - parseFloat(cs.paddingRight) } })() : null
|
|
334
|
+
const codeRects = code ? [...code.getClientRects()].map((r) => ({ left: Math.round(r.left), right: Math.round(r.right) })) : []
|
|
335
|
+
return {
|
|
336
|
+
heading: flat(card?.querySelector('h1')?.textContent),
|
|
337
|
+
identity: flat(card?.querySelector('h1 + p')?.textContent),
|
|
338
|
+
field: !!card?.querySelector('input#passphrase[type="password"]'),
|
|
339
|
+
button: flat(card?.querySelector('form button[type="submit"]')?.textContent),
|
|
340
|
+
lead: flat(card?.querySelector('p.lead')?.textContent),
|
|
341
|
+
leadBold: !!card?.querySelector('p.lead > b'),
|
|
342
|
+
summary: flat(fold?.querySelector('summary')?.textContent),
|
|
343
|
+
open: fold ? fold.open : null,
|
|
344
|
+
// A closed fold keeps its content laid out but hidden, so a rectangle is not the test; visibility is.
|
|
345
|
+
hintsShown: hints.filter((h) => h.checkVisibility()).length,
|
|
346
|
+
hints: hints.map((h) => flat(h.textContent)),
|
|
347
|
+
setUp: fold?.querySelector('a') ? { text: flat(fold.querySelector('a').textContent), href: fold.querySelector('a').getAttribute('href') } : null,
|
|
348
|
+
code: flat(code?.textContent),
|
|
349
|
+
// The card's own width: its content box, which the page caps at 420px.
|
|
350
|
+
cardWidth: card ? Math.round(card.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth)) : null,
|
|
351
|
+
codeInside: !!inner && codeRects.length > 0 && codeRects.every((r) => r.left >= inner.left - 1 && r.right <= inner.right + 1),
|
|
352
|
+
codeLines: codeRects.length,
|
|
353
|
+
overflowsPage: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
354
|
+
}
|
|
355
|
+
})()`
|
|
356
|
+
|
|
357
|
+
const out = {}
|
|
358
|
+
const SPEC = {
|
|
359
|
+
signInName: "Through your organisation's sign-in service",
|
|
360
|
+
webSearch: ['The engine’s own web search', 'provided by the engine', 'Available', 'The model searches the web itself during a run.'],
|
|
361
|
+
categories: ['Trademark register', 'Common-law and marketplace research', 'Open-web search', 'Case law and oppositions'],
|
|
362
|
+
caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
|
|
363
|
+
adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
|
|
364
|
+
blurLine: 'On the top bar. It covers every mark and company on screen, and an open report whole. It stays as you left it on this computer.',
|
|
365
|
+
reset: 'Lost the passphrase? Run clearotron passphrase --reset on the machine running this portal. It mints a new one and prints it once.',
|
|
366
|
+
addPeople: 'To add people, put it behind a login system such as your company single sign-on. How to set that up',
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const theme of ['light', 'dark']) {
|
|
370
|
+
console.log(`\n── ${theme} ──`)
|
|
371
|
+
|
|
372
|
+
// ── Installation settings
|
|
373
|
+
console.log('\nInstallation settings:')
|
|
374
|
+
loads.length = 0
|
|
375
|
+
if (await open('/portal/admin/config', "document.querySelectorAll('.cfg-row').length >= 10", 'Installation settings drew its rows')) {
|
|
376
|
+
await setTheme(theme)
|
|
377
|
+
const s = (await evalIn(settingsProbe)) ?? {}
|
|
378
|
+
out[`settings-${theme}`] = s
|
|
379
|
+
ok(s.renderedChars > 400, `the page rendered real text (${s.renderedChars} chars) — every absence below is worthless without it`)
|
|
380
|
+
ok(s.title === 'Installation settings', `the page is titled "Installation settings" (saw "${s.title}")`)
|
|
381
|
+
ok(same(s.sections, ['Sign-in', 'Engine', 'Providers']), `three sections: Sign-in, Engine, Providers (saw ${JSON.stringify(s.sections)})`)
|
|
382
|
+
ok(s.signIn.length === 1, `Sign-in is one row (saw ${s.signIn.length})`)
|
|
383
|
+
const si = s.signIn[0]
|
|
384
|
+
ok(si && si.name === SPEC.signInName && si.val === null && si.state === 'Configured',
|
|
385
|
+
`the sign-in row reads "${SPEC.signInName} · Configured" (saw ${JSON.stringify(si)})`)
|
|
386
|
+
ok(si && same(si.mono, [`auth-proxy · issuer ${ISSUER}`]), `the mode and issuer are the row's quiet mono line (saw ${JSON.stringify(si?.mono)})`)
|
|
387
|
+
const rights = [...s.signIn, ...s.engine, ...s.categories.flatMap((c) => c.rows)].map((r) => r.stateRight)
|
|
388
|
+
ok(rights.length >= 10 && new Set(rights).size === 1, `every state lines up in one column (right edges ${JSON.stringify([...new Set(rights)])})`)
|
|
389
|
+
ok(s.engine.length === 2 && s.engine[0].name === 'Anthropic' && s.engine[0].state === 'Subscription',
|
|
390
|
+
`Engine carries the engine row first (saw ${JSON.stringify(s.engine[0])})`)
|
|
391
|
+
const web = s.engine[1]
|
|
392
|
+
ok(web && same([web.name, web.val, web.state, web.notes[0]], SPEC.webSearch),
|
|
393
|
+
`the engine's own web search sits under Engine, in the page's words (saw ${JSON.stringify(web)})`)
|
|
394
|
+
ok(same(s.categories.map((c) => c.label), SPEC.categories), `four provider categories, in order (saw ${JSON.stringify(s.categories.map((c) => c.label))})`)
|
|
395
|
+
const repeated = s.categories.flatMap((c) => c.rows.filter((r) => [r.name, r.val ?? '', ...r.notes].some((t) => t.includes(c.label))).map((r) => `${c.label} on ${r.name}`))
|
|
396
|
+
ok(repeated.length === 0, `no category name is repeated on a row (saw ${JSON.stringify(repeated)})`)
|
|
397
|
+
ok(!s.categories.flatMap((c) => c.rows).some((r) => /web search/i.test(r.name)), 'the engine\'s web search is not also listed as a provider')
|
|
398
|
+
const rows = Object.fromEntries(s.categories.flatMap((c) => c.rows).map((r) => [r.name, r]))
|
|
399
|
+
ok(rows.Corsearch?.state === 'Configured' && rows.Perplexity?.state === 'Configured', 'the configured register and research key read Configured')
|
|
400
|
+
ok(rows.SerpAPI?.state === 'Missing' && same(rows.SerpAPI?.notes, ['A key is needed'])
|
|
401
|
+
&& rows.SerpAPI?.guide?.text === 'Setup guide' && /providers\/README\.md#research-and-support-sources$/.test(rows.SerpAPI?.guide?.href ?? '') && rows.SerpAPI?.guide?.target === '_blank',
|
|
402
|
+
`a missing key says "A key is needed" beside a Setup guide that opens its guide (saw ${JSON.stringify(rows.SerpAPI)})`)
|
|
403
|
+
for (const name of ['CourtListener', 'Legal Data Hunter']) {
|
|
404
|
+
ok(rows[name]?.state === 'Not set up' && same(rows[name]?.notes, ['One-time sign-in needed']) && rows[name]?.guide?.text === 'Setup guide'
|
|
405
|
+
&& /oauth-mcp-bridge\/README\.md#one-time-setup-per-remote-mcp-server$/.test(rows[name]?.guide?.href ?? ''),
|
|
406
|
+
`${name} says "One-time sign-in needed" beside its Setup guide (saw ${JSON.stringify(rows[name])})`)
|
|
407
|
+
}
|
|
408
|
+
ok(rows.CourtListener?.val === 'US federal case law' && rows['Legal Data Hunter']?.val === 'statutes and case law, 108 countries' && rows['EUR-Lex']?.val === 'EU judgments',
|
|
409
|
+
'each case-law source says what it covers, without its web address')
|
|
410
|
+
const caseLaw = s.categories.find((c) => c.label === 'Case law and oppositions')
|
|
411
|
+
ok(caseLaw?.note === SPEC.caseLawGap, `what an unset case-law source costs is said under the category (saw ${JSON.stringify(caseLaw?.note)})`)
|
|
412
|
+
ok(!s.categories.flatMap((c) => c.rows).some((r) => r.notes.some((n) => /case-law gap/.test(n))), 'and not again on a row')
|
|
413
|
+
const boa = rows['EUIPO Boards of Appeal']
|
|
414
|
+
ok(boa?.state === 'Not in this build' && same(boa?.notes, ['Reports covering the EU say so']) && boa?.guide === null,
|
|
415
|
+
`a source this build does not ship says which reports disclose it, and offers no guide (saw ${JSON.stringify(boa)})`)
|
|
416
|
+
ok(s.leaks.length === 0, `no variable name, credential path or README reference anywhere on the page (saw ${JSON.stringify(s.leaks)})`)
|
|
417
|
+
const menu = (await evalIn(menuProbe)) ?? {}
|
|
418
|
+
ok(Array.isArray(menu) && menu.includes('Installation settings') && !menu.includes('Global config'),
|
|
419
|
+
`the avatar menu names the page "Installation settings" (saw ${JSON.stringify(menu)})`)
|
|
420
|
+
await capture(`installation-settings-${theme}`)
|
|
421
|
+
// Counted after the Setup guide links were read, which needs the second load to have answered.
|
|
422
|
+
await sleep(500)
|
|
423
|
+
const asked = (route) => loads.filter((l) => l === route).length
|
|
424
|
+
ok(asked('/portal/admin/config') === 1 && asked('/portal/api/about') === 1,
|
|
425
|
+
`one visit asks for the settings once and for the source address once (saw ${JSON.stringify(loads)})`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Preferences, contact set and unset
|
|
429
|
+
for (const [label, contact] of [['contact-set', CONTACT], ['contact-unset', null]]) {
|
|
430
|
+
console.log(`\nPreferences, ${label}:`)
|
|
431
|
+
state.contact = contact
|
|
432
|
+
if (!(await open('/portal/preferences', "document.querySelector('.pref-blur button')", `Preferences drew (${label})`))) continue
|
|
433
|
+
await setTheme(theme)
|
|
434
|
+
const p = (await evalIn(preferencesProbe)) ?? {}
|
|
435
|
+
out[`preferences-${label}-${theme}`] = p
|
|
436
|
+
ok(p.renderedChars > 200, `the page rendered real text (${p.renderedChars} chars)`)
|
|
437
|
+
ok(p.title === 'Your preferences', `titled "Your preferences" (saw "${p.title}")`)
|
|
438
|
+
ok(same(p.cards, ['Your sign-in', 'Appearance', 'Blur names while sharing a screen']), `three cards, in order (saw ${JSON.stringify(p.cards)})`)
|
|
439
|
+
ok(same(p.rows, [`Address: ${EMAIL}`, 'Permissions: Runs clearances · Manages', 'Access to: Everything']),
|
|
440
|
+
`Your sign-in carries the address, the permissions and the access (saw ${JSON.stringify(p.rows)})`)
|
|
441
|
+
ok(p.lines[0] === SPEC.adminLine, `the administrator line reads as written (saw ${JSON.stringify(p.lines[0])})`)
|
|
442
|
+
if (contact) ok(p.link?.text === 'Clearotron administrator' && p.link?.href === CONTACT, `with a contact set, "Clearotron administrator" links to it (saw ${JSON.stringify(p.link)})`)
|
|
443
|
+
else ok(p.link === null, `with no contact set, "Clearotron administrator" is plain text (saw ${JSON.stringify(p.link)})`)
|
|
444
|
+
ok(p.logOut === 'Log out', 'Log out is where it was')
|
|
445
|
+
ok(p.lines[1] === 'Also on the top bar, under the circle icon', `the appearance line reads as written (saw ${JSON.stringify(p.lines[1])})`)
|
|
446
|
+
ok(p.blurLine === SPEC.blurLine, `the blur card states its three facts (saw ${JSON.stringify(p.blurLine)})`)
|
|
447
|
+
ok(p.eye && p.eye.label === 'Blur names for screen sharing' && p.eye.text === '' && p.eye.pressed === 'false' && p.topEye?.cls === p.eye.cls && p.topEye?.icon,
|
|
448
|
+
`the card shows the top bar's own eye button, unpressed, with no label or badge (saw ${JSON.stringify({ eye: p.eye, top: p.topEye })})`)
|
|
449
|
+
ok(!p.notKept, 'no sentence on the page says the blur choice is not kept')
|
|
450
|
+
await capture(`preferences-${label}-${theme}`)
|
|
451
|
+
if (!contact) {
|
|
452
|
+
// THE THEME CHOICE STILL WORKS: the other option applies at once, and pressing this one brings it back.
|
|
453
|
+
const other = theme === 'light' ? 2 : 1
|
|
454
|
+
await press(`.pref-themes button:nth-child(${other})`)
|
|
455
|
+
const flipped = await evalIn("[document.documentElement.getAttribute('data-theme'), [...document.querySelectorAll('.pref-themes button')].map((b) => b.getAttribute('aria-pressed'))]")
|
|
456
|
+
await press(`.pref-themes button:nth-child(${3 - other})`)
|
|
457
|
+
const back = await evalIn("document.documentElement.getAttribute('data-theme')")
|
|
458
|
+
ok(same(flipped, [theme === 'light' ? 'dark' : 'light', other === 2 ? ['false', 'true'] : ['true', 'false']]) && back === theme,
|
|
459
|
+
`the theme choice applies and comes back (saw ${JSON.stringify({ flipped, back })})`)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
state.contact = CONTACT
|
|
463
|
+
|
|
464
|
+
// ── the blur, remembered
|
|
465
|
+
console.log('\nBlur, remembered:')
|
|
466
|
+
if (await open('/portal/preferences', "document.querySelector('.pref-blur button')", 'Preferences drew for the blur')) {
|
|
467
|
+
await evalIn(`(() => { try { localStorage.removeItem('${BLUR_KEY}') } catch {} return true })()`)
|
|
468
|
+
await setTheme(theme)
|
|
469
|
+
await press('.pref-blur button')
|
|
470
|
+
const on = (await evalIn(preferencesProbe)) ?? {}
|
|
471
|
+
ok(on.blurredDocument && on.eye?.pressed === 'true' && on.topEye?.pressed === 'true' && /blur/.test(on.addressFilter ?? ''),
|
|
472
|
+
`pressing the card's eye blurs the page and presses both buttons (saw ${JSON.stringify({ blurred: on.blurredDocument, card: on.eye?.pressed, top: on.topEye?.pressed, filter: on.addressFilter })})`)
|
|
473
|
+
ok(on.stored === 'on', `the choice is kept in this browser (saw ${JSON.stringify(on.stored)})`)
|
|
474
|
+
ok(on.companyDrawn && on.unmarkedCompany.length === 0,
|
|
475
|
+
`with the blur on, the company's name is drawn unmarked for the blur at: ${JSON.stringify(on.unmarkedCompany)} (drawn at all: ${on.companyDrawn})`)
|
|
476
|
+
await capture(`blur-on-${theme}`)
|
|
477
|
+
if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew again after the reload')) {
|
|
478
|
+
await sleep(300)
|
|
479
|
+
const back = (await evalIn(preferencesProbe)) ?? {}
|
|
480
|
+
ok(back?.blurredDocument && back.eye?.pressed === 'true' && back.topEye?.pressed === 'true' && /blur/.test(back.addressFilter ?? ''),
|
|
481
|
+
`after a reload the page is still blurred and the eye is still pressed (saw ${JSON.stringify({ blurred: back?.blurredDocument, card: back?.eye?.pressed, top: back?.topEye?.pressed, filter: back?.addressFilter })})`)
|
|
482
|
+
await capture(`blur-reloaded-${theme}`)
|
|
483
|
+
// THE TWO BUTTONS ARE ONE CONTROL: the top bar turns it off, the card agrees, and the next press of
|
|
484
|
+
// the card turns it on again rather than doing nothing.
|
|
485
|
+
await press('header.topbar button[aria-label="Blur names for screen sharing"]')
|
|
486
|
+
const off = (await evalIn(preferencesProbe)) ?? {}
|
|
487
|
+
ok(!off.blurredDocument && off.eye?.pressed === 'false' && off.topEye?.pressed === 'false' && off.stored === 'off',
|
|
488
|
+
`the top bar's eye turns it off and the card agrees (saw ${JSON.stringify({ blurred: off.blurredDocument, card: off.eye?.pressed, stored: off.stored })})`)
|
|
489
|
+
await press('.pref-blur button')
|
|
490
|
+
const again = (await evalIn(preferencesProbe)) ?? {}
|
|
491
|
+
ok(again.blurredDocument && again.topEye?.pressed === 'true', 'and the card\'s next press turns it on again — no dead click')
|
|
492
|
+
await press('.pref-blur button')
|
|
493
|
+
if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew after turning the blur off')) {
|
|
494
|
+
await sleep(300)
|
|
495
|
+
const stayedOff = (await evalIn(preferencesProbe)) ?? {}
|
|
496
|
+
ok(stayedOff && !stayedOff.blurredDocument && stayedOff.eye?.pressed === 'false', 'turned off, it stays off after a reload')
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── About
|
|
502
|
+
console.log('\nAbout:')
|
|
503
|
+
if (await open('/portal/about', "document.querySelector('.about-dl')", 'About drew')) {
|
|
504
|
+
await setTheme(theme)
|
|
505
|
+
const a = (await evalIn(aboutProbe)) ?? {}
|
|
506
|
+
out[`about-${theme}`] = a
|
|
507
|
+
ok(a.title === 'About', `titled "About" (saw "${a.title}")`)
|
|
508
|
+
ok(same(a.rows, ['Product', 'Version', 'Build', 'Source', 'Licence', 'Copyright', 'Model access', 'Trade marks']), `the rows, in order (saw ${JSON.stringify(a.rows)})`)
|
|
509
|
+
ok(a.source?.text === 'CordilleraSarl/Clearotron ↗' && a.source?.href === about.sourceUrl && a.source?.lines === 1,
|
|
510
|
+
`the Source row is a readable one-line link to this build's source (saw ${JSON.stringify(a.source)})`)
|
|
511
|
+
ok(about.commit ? a.build === about.commit.slice(0, 12) && /mono|monospace|Fira|Menlo|Courier/i.test(a.buildFont ?? '') : a.build === null,
|
|
512
|
+
`the build identifier stays one row above it, in mono (saw ${JSON.stringify({ build: a.build, font: a.buildFont })})`)
|
|
513
|
+
ok(a.fullText?.text === 'Full text' && /gnu\.org/.test(a.fullText?.href ?? ''), `the licence keeps "Full text" (saw ${JSON.stringify(a.fullText)})`)
|
|
514
|
+
ok(a.copyright === '2026 Cordillera Sàrl', `copyright (saw ${JSON.stringify(a.copyright)})`)
|
|
515
|
+
ok(same(a.pills, ['Notices', 'Trademarks', 'Contributing', 'Security', 'Code of conduct', 'GitHub', 'clearotron.ai']), `the link pills (saw ${JSON.stringify(a.pills)})`)
|
|
516
|
+
await capture(`about-${theme}`)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Sign in: help closed, open, and with a long reset command
|
|
520
|
+
for (const [label, reset] of [['sign-in-help', null], ['sign-in-help-long-command', LONG_RESET]]) {
|
|
521
|
+
console.log(`\n${label}:`)
|
|
522
|
+
state.reset = reset
|
|
523
|
+
if (!(await open('/portal/login', "document.querySelector('.card details.fold')", `the sign-in page drew (${label})`))) continue
|
|
524
|
+
await setTheme(theme)
|
|
525
|
+
const closed = (await evalIn(signInProbe)) ?? {}
|
|
526
|
+
out[`${label}-closed-${theme}`] = closed
|
|
527
|
+
ok(closed.heading === 'Sign in' && closed.identity === `Clearotron portal, as ${EMAIL}.` && closed.field && closed.button === 'Sign in',
|
|
528
|
+
`the card keeps its heading, identity line, field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
|
|
529
|
+
ok(closed.lead === 'This Clearotron signs in one person: you.' && closed.leadBold, `one bold line under the button (saw ${JSON.stringify(closed.lead)})`)
|
|
530
|
+
ok(closed.summary === 'Administrator help' && closed.open === false && closed.hintsShown === 0,
|
|
531
|
+
`the administrator's lines sit in a closed "Administrator help" fold (saw ${JSON.stringify({ summary: closed.summary, open: closed.open, shown: closed.hintsShown })})`)
|
|
532
|
+
if (!reset) await capture(`sign-in-help-closed-${theme}`)
|
|
533
|
+
await press('.card details.fold > summary')
|
|
534
|
+
const opened = (await evalIn(signInProbe)) ?? {}
|
|
535
|
+
out[`${label}-open-${theme}`] = opened
|
|
536
|
+
ok(opened.open === true && opened.hintsShown === 2, `pressing it opens both lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
|
|
537
|
+
ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.addPeople,
|
|
538
|
+
`the fold holds the product's two lines word for word (saw ${JSON.stringify(opened.hints)})`)
|
|
539
|
+
ok(opened.setUp?.text === 'How to set that up' && opened.setUp?.href === LOGIN_IN_FRONT_DOC, `with the "How to set that up" link (saw ${JSON.stringify(opened.setUp)})`)
|
|
540
|
+
ok(opened.codeInside && !opened.overflowsPage && opened.cardWidth <= 420,
|
|
541
|
+
`the reset command stays inside the card at its own width (card ${opened.cardWidth}px, ${opened.codeLines} line(s), inside: ${opened.codeInside})`)
|
|
542
|
+
await capture(`${label}-open-${theme}`)
|
|
543
|
+
}
|
|
544
|
+
state.reset = null
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── a browser whose storage refuses ──────────────────────────────────────────────────────────────────
|
|
548
|
+
//
|
|
549
|
+
// localStorage throws outright in a null-origin or locked-down document. The page must still draw, the
|
|
550
|
+
// eye must still blur, and a reload must start it off rather than take the page down.
|
|
551
|
+
console.log('\nBlur, in a browser whose storage refuses:')
|
|
552
|
+
const { result: refusal } = await cmd('Page.addScriptToEvaluateOnNewDocument', {
|
|
553
|
+
source: "Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('The operation is insecure.', 'SecurityError') } })",
|
|
554
|
+
})
|
|
555
|
+
if (await open('/portal/preferences', "document.querySelector('.pref-blur button')", 'Preferences drew in a browser whose storage refuses')) {
|
|
556
|
+
const before = (await evalIn(preferencesProbe)) ?? {}
|
|
557
|
+
ok(before.stored === 'refused' && !before.blurredDocument && before.eye?.pressed === 'false', `the storage refuses, and the page starts unblurred (saw ${JSON.stringify({ stored: before.stored, blurred: before.blurredDocument })})`)
|
|
558
|
+
await press('.pref-blur button')
|
|
559
|
+
const pressed = (await evalIn(preferencesProbe)) ?? {}
|
|
560
|
+
ok(pressed.blurredDocument && pressed.eye?.pressed === 'true' && pressed.topEye?.pressed === 'true', 'pressing the eye still blurs the page')
|
|
561
|
+
if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew again after a reload with storage refusing')) {
|
|
562
|
+
const after = (await evalIn(preferencesProbe)) ?? {}
|
|
563
|
+
ok(after && !after.blurredDocument && after.eye?.pressed === 'false', 'a reload starts it off, with nothing to remember it by, and the page still draws')
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
await cmd('Page.removeScriptToEvaluateOnNewDocument', { identifier: refusal?.identifier })
|
|
567
|
+
|
|
568
|
+
if (!process.argv.includes('--quiet')) console.log(`\n${JSON.stringify(out, null, 2)}`)
|
|
569
|
+
try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
570
|
+
server.close()
|
|
571
|
+
rmSync(userDir, { recursive: true, force: true })
|
|
572
|
+
rmSync(credsDir, { recursive: true, force: true })
|
|
573
|
+
if (shotDir) console.log(`\nwrote ${shots.length} screenshot(s) to ${shotDir}:\n ${shots.join('\n ')}`)
|
|
574
|
+
console.log(failures ? `\n${failures} failed` : '\nInstallation settings, Preferences, About and Sign in draw correctly in both themes')
|
|
575
|
+
process.exit(failures ? 1 : 0)
|