dsh-standards 0.0.3 → 1.0.1

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.
Files changed (139) hide show
  1. package/40-checker/README.md +251 -0
  2. package/40-checker/bin/dsh-levels.mjs +15 -0
  3. package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
  4. package/40-checker/fixtures/bad/index.mjs +12 -0
  5. package/40-checker/fixtures/bad/package.json +15 -0
  6. package/40-checker/fixtures/bad-manifest/index.js +1 -0
  7. package/40-checker/fixtures/bad-manifest/package.json +5 -0
  8. package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
  9. package/40-checker/fixtures/bundle/package.json +15 -0
  10. package/40-checker/fixtures/bundle/server.mjs +7 -0
  11. package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
  12. package/40-checker/fixtures/bundle-v0/package.json +21 -0
  13. package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
  14. package/40-checker/fixtures/client-missing/client.js +1 -0
  15. package/40-checker/fixtures/client-missing/index.js +1 -0
  16. package/40-checker/fixtures/client-missing/package.json +6 -0
  17. package/40-checker/fixtures/flow-config/index.js +9 -0
  18. package/40-checker/fixtures/flow-config/package.json +10 -0
  19. package/40-checker/fixtures/fs-write/index.mjs +7 -0
  20. package/40-checker/fixtures/fs-write/package.json +10 -0
  21. package/40-checker/fixtures/good/index.mjs +10 -0
  22. package/40-checker/fixtures/good/package.json +19 -0
  23. package/40-checker/fixtures/hang/index.mjs +5 -0
  24. package/40-checker/fixtures/hang/package.json +10 -0
  25. package/40-checker/fixtures/known-answer/expected.json +18 -0
  26. package/40-checker/fixtures/market-index-real.json +18 -0
  27. package/40-checker/fixtures/market-index.json +18 -0
  28. package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
  29. package/40-checker/fixtures/negative/no-entry/package.json +1 -0
  30. package/40-checker/fixtures/negative/no-patch/package.json +1 -0
  31. package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
  32. package/40-checker/fixtures/negative/root-missing/package.json +1 -0
  33. package/40-checker/fixtures/negative/workspace/package.json +1 -0
  34. package/40-checker/package.json +22 -0
  35. package/40-checker/samples/README.md +8 -0
  36. package/40-checker/samples/entry18.json +139 -0
  37. package/40-checker/samples/frame.json +485 -0
  38. package/40-checker/scripts/apply-overlay.mjs +59 -0
  39. package/40-checker/scripts/attestation-check.mjs +151 -0
  40. package/40-checker/scripts/bundle-smoke.mjs +126 -0
  41. package/40-checker/scripts/claim-check.mjs +145 -0
  42. package/40-checker/scripts/clause-map.mjs +94 -0
  43. package/40-checker/scripts/collect-fixtures.mjs +44 -0
  44. package/40-checker/scripts/dashboard.mjs +85 -0
  45. package/40-checker/scripts/dco-check.mjs +147 -0
  46. package/40-checker/scripts/dependabot-check.mjs +40 -0
  47. package/40-checker/scripts/doc-commands-check.mjs +92 -0
  48. package/40-checker/scripts/entry-scan.mjs +190 -0
  49. package/40-checker/scripts/entry-validate.mjs +291 -0
  50. package/40-checker/scripts/entry18-attribution.mjs +66 -0
  51. package/40-checker/scripts/entry18-ci.mjs +685 -0
  52. package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
  53. package/40-checker/scripts/entry18-web.mjs +307 -0
  54. package/40-checker/scripts/evidence-chain.mjs +100 -0
  55. package/40-checker/scripts/flow-index.mjs +82 -0
  56. package/40-checker/scripts/flow-sample-ci.mjs +529 -0
  57. package/40-checker/scripts/governance-check.mjs +210 -0
  58. package/40-checker/scripts/host-farm.mjs +165 -0
  59. package/40-checker/scripts/journey-config-observe.mjs +109 -0
  60. package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
  61. package/40-checker/scripts/journey-runtime.mjs +178 -0
  62. package/40-checker/scripts/journey.mjs +76 -0
  63. package/40-checker/scripts/label-drift.mjs +74 -0
  64. package/40-checker/scripts/label-sync.mjs +87 -0
  65. package/40-checker/scripts/label-verify.mjs +90 -0
  66. package/40-checker/scripts/landlock-exec.c +101 -0
  67. package/40-checker/scripts/level-audit.mjs +336 -0
  68. package/40-checker/scripts/m1-sidecar.mjs +126 -0
  69. package/40-checker/scripts/m2-runner.mjs +213 -0
  70. package/40-checker/scripts/round-robin.mjs +88 -0
  71. package/40-checker/scripts/sample-frame.mjs +72 -0
  72. package/40-checker/scripts/sample20-ci.mjs +25 -0
  73. package/40-checker/scripts/sample60-ci.mjs +25 -0
  74. package/40-checker/scripts/stale-check.mjs +59 -0
  75. package/40-checker/scripts/surveillance.mjs +88 -0
  76. package/40-checker/scripts/web-probe.mjs +504 -0
  77. package/40-checker/src/cli.mjs +300 -0
  78. package/40-checker/src/client-shape.mjs +37 -0
  79. package/40-checker/src/declarations.mjs +242 -0
  80. package/40-checker/src/index.mjs +8 -0
  81. package/40-checker/src/install-spec.mjs +67 -0
  82. package/40-checker/src/label.mjs +130 -0
  83. package/40-checker/src/manifest.mjs +381 -0
  84. package/40-checker/src/permissions.mjs +60 -0
  85. package/40-checker/src/report.mjs +204 -0
  86. package/40-checker/src/result.mjs +60 -0
  87. package/40-checker/src/runtime.mjs +299 -0
  88. package/40-checker/src/semver.mjs +194 -0
  89. package/40-checker/src/util.mjs +148 -0
  90. package/40-checker/test/attestation.test.mjs +59 -0
  91. package/40-checker/test/checks.test.mjs +172 -0
  92. package/40-checker/test/claim.test.mjs +28 -0
  93. package/40-checker/test/client-shape.test.mjs +35 -0
  94. package/40-checker/test/dco.test.mjs +51 -0
  95. package/40-checker/test/entry-validate.test.mjs +172 -0
  96. package/40-checker/test/governance.test.mjs +76 -0
  97. package/40-checker/test/label.test.mjs +97 -0
  98. package/40-checker/test/level-audit.test.mjs +99 -0
  99. package/40-checker/test/levels.test.mjs +31 -0
  100. package/40-checker/test/ops.test.mjs +117 -0
  101. package/40-checker/test/permissions.test.mjs +40 -0
  102. package/40-checker/test/semver.test.mjs +32 -0
  103. package/40-checker/test/verify.test.mjs +57 -0
  104. package/CHANGELOG.md +24 -0
  105. package/LICENSE +28 -0
  106. package/LICENSE-APACHE +190 -0
  107. package/LICENSE-CC-BY +23 -0
  108. package/NOTICE +14 -0
  109. package/NOTICE.md +11 -0
  110. package/README.md +146 -3
  111. package/bin/dsh-levels.mjs +4 -0
  112. package/bin/dsh-standards.mjs +19 -0
  113. package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
  114. package/docs/DEVELOPER-JOURNEY.md +40 -0
  115. package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
  116. package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
  117. package/docs/NAMING-FALLBACK.md +11 -0
  118. package/docs/fork-policy.md +12 -0
  119. package/docs/full-test-plan-2026-11-02.md +207 -0
  120. package/docs/incident.md +37 -0
  121. package/docs/onboarding.md +27 -0
  122. package/docs/privacy.md +12 -0
  123. package/docs/release.md +16 -0
  124. package/docs/scope-security.md +62 -0
  125. package/docs/status-taxonomy.md +54 -0
  126. package/docs/sunset.md +44 -0
  127. package/docs/translating.md +12 -0
  128. package/package.json +58 -12
  129. package/spec/README.md +27 -0
  130. package/spec/boost/README.md +8 -0
  131. package/spec/clauses.json +253 -0
  132. package/spec/entry/README.md +9 -0
  133. package/spec/flow/README.md +8 -0
  134. package/spec/levels.json +371 -0
  135. package/spec/levels.schema.json +45 -0
  136. package/spec/reference-suite.json +35 -0
  137. package/spec/schemas/attestation.schema.json +284 -0
  138. package/spec/schemas/evidence.schema.json +331 -0
  139. package/spec/schemas/report.schema.json +64 -0
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Real-browser probe for the DSH Web host page.
4
+ *
5
+ * Opens the token URL printed by `dsh --profile <name> ...`, lets the browser
6
+ * exchange the per-process launch token for the signed `dsh-auth-*` cookie,
7
+ * waits for the composed client graph and for the boot page to be replaced by
8
+ * the mounted application, then records console/page errors, failed same-origin
9
+ * JavaScript requests, the `window.__DSH_BOOT__` wire graph, and resource
10
+ * timings for the expected package bundles.
11
+ *
12
+ * This probes the client *module loading path* in a real browser. It does not
13
+ * prove any plugin slot is visually correct; call a passing row
14
+ * `client-module-loaded`, not `UI-rendered`.
15
+ *
16
+ * Usage:
17
+ * node web-probe.mjs --url <token-url> --expect pkg-a,pkg-b \
18
+ * --out result.json --screenshot shot.png
19
+ *
20
+ * Optional:
21
+ * --login-file <file> Username/password bootstrap file for a gated app
22
+ * (dsh-ui-auth). The probe logs in through
23
+ * POST /auth/login before opening the token URL.
24
+ * --executable <path> Explicit Chromium executable (local runs without a
25
+ * Playwright-managed browser).
26
+ * --timeout <ms> Total wait budget for graph + mount (default 90000).
27
+ *
28
+ * Exit codes: 0 ok, 1 probe failure, 2 usage error.
29
+ *
30
+ * The token URL is treated as a credential: it is never written to the result
31
+ * JSON and never printed by this script.
32
+ */
33
+
34
+ import fs from 'node:fs'
35
+ import path from 'node:path'
36
+ import { pathToFileURL } from 'node:url'
37
+
38
+ export const SCHEMA = 'dsh-web-probe/1'
39
+ export const DEFAULT_TIMEOUT_MS = 90_000
40
+ const GRAPH_WAIT_MS = 30_000
41
+ const LOGIN_PATH = '/auth/login'
42
+
43
+ /**
44
+ * Split a comma-separated `--expect` value into package ids.
45
+ * @param {string} value - raw comma-separated value.
46
+ * @returns {string[]} trimmed, non-empty ids.
47
+ */
48
+ export function parseExpected(value) {
49
+ if (typeof value !== 'string') return []
50
+ return value.split(',').map(item => item.trim()).filter(Boolean)
51
+ }
52
+
53
+ /**
54
+ * Parse the first-run credential file written by dsh-ui-auth.
55
+ *
56
+ * Accepts either JSON (`{"username":"...","password":"..."}`) or the shipped
57
+ * text form:
58
+ * 用户名: admin
59
+ * 密码: secret
60
+ *
61
+ * @param {string} text - file contents.
62
+ * @returns {{username: string, password: string}} parsed credentials.
63
+ * @throws {Error} when either field is absent.
64
+ */
65
+ export function parseCredentialFile(text) {
66
+ if (typeof text !== 'string' || text.trim() === '') {
67
+ throw new Error('credential file is empty')
68
+ }
69
+ try {
70
+ const parsed = JSON.parse(text)
71
+ if (parsed !== null && typeof parsed === 'object'
72
+ && typeof parsed.username === 'string' && typeof parsed.password === 'string') {
73
+ return { username: parsed.username, password: parsed.password }
74
+ }
75
+ } catch {
76
+ // Not JSON; fall through to the shipped text format.
77
+ }
78
+ const username = /用户名\s*[::]\s*(\S+)/u.exec(text)?.[1]
79
+ const password = /密码\s*[::]\s*(\S+)/u.exec(text)?.[1]
80
+ if (username === undefined || password === undefined) {
81
+ throw new Error('credential file has no username/password pair')
82
+ }
83
+ return { username, password }
84
+ }
85
+
86
+ /**
87
+ * Pure projection of the `window.__DSH_BOOT__` wire graph and resource timings.
88
+ *
89
+ * Kept free of browser imports so it can be exercised by a Node fixture.
90
+ *
91
+ * @param {unknown} wire - raw `window.__DSH_BOOT__` value (undefined when absent).
92
+ * @param {string[]} expected - package ids that must appear in the graph.
93
+ * @param {string[]} resourceUrls - `performance.getEntriesByType('resource')` names.
94
+ * @returns {{
95
+ * bootGraph: 'ok'|'missing'|'invalid',
96
+ * bootRev: string|null,
97
+ * bootIds: string[],
98
+ * loadedEntries: string[],
99
+ * missingEntries: string[],
100
+ * rows: Array<{id: string, booted: boolean, loaded: boolean, url: string|null, matchedResource: string|null}>,
101
+ * error?: string,
102
+ * }} normalized projection.
103
+ */
104
+ export function summarizeBootGraph(wire, expected = [], resourceUrls = []) {
105
+ const ids = Array.isArray(expected) ? expected.filter(id => typeof id === 'string' && id !== '') : []
106
+ const resources = Array.isArray(resourceUrls) ? resourceUrls.filter(url => typeof url === 'string') : []
107
+ const empty = {
108
+ bootGraph: 'missing',
109
+ bootRev: null,
110
+ bootIds: [],
111
+ loadedEntries: [],
112
+ missingEntries: [...ids],
113
+ rows: ids.map(id => ({ id, booted: false, loaded: false, url: null, matchedResource: null })),
114
+ }
115
+ if (wire === undefined || wire === null) return empty
116
+ if (typeof wire !== 'object' || !Array.isArray(/** @type {any} */ (wire).entries)) {
117
+ return { ...empty, bootGraph: 'invalid', error: 'window.__DSH_BOOT__.entries is not an array' }
118
+ }
119
+ const graph = /** @type {any} */ (wire)
120
+ const entries = graph.entries.filter(entry => entry !== null && typeof entry === 'object' && typeof entry.id === 'string')
121
+ const bootIds = entries.map(entry => entry.id)
122
+ const rows = ids.map(id => {
123
+ const entry = entries.find(candidate => candidate.id === id)
124
+ if (entry === undefined) {
125
+ return { id, booted: false, loaded: false, url: null, matchedResource: null }
126
+ }
127
+ const entryUrl = typeof entry.url === 'string' ? entry.url : null
128
+ const encoded = encodeURIComponent(id)
129
+ const candidates = [entryUrl, id, encoded].filter(value => typeof value === 'string' && value !== '')
130
+ const matchedResource = resources.find(url => candidates.some(candidate => url.includes(candidate))) ?? null
131
+ return {
132
+ id,
133
+ booted: true,
134
+ loaded: matchedResource !== null,
135
+ url: entryUrl,
136
+ matchedResource,
137
+ }
138
+ })
139
+ return {
140
+ bootGraph: 'ok',
141
+ bootRev: typeof graph.rev === 'string' ? graph.rev : null,
142
+ bootIds,
143
+ loadedEntries: rows.filter(row => row.loaded).map(row => row.id),
144
+ missingEntries: rows.filter(row => !row.booted).map(row => row.id),
145
+ rows,
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Parse probe argv.
151
+ * @param {string[]} argv - arguments after `node script`.
152
+ * @returns {object} parsed options.
153
+ */
154
+ function parseArgs(argv) {
155
+ const options = {
156
+ url: null,
157
+ expect: [],
158
+ out: null,
159
+ screenshot: null,
160
+ loginFile: null,
161
+ executable: null,
162
+ timeout: DEFAULT_TIMEOUT_MS,
163
+ help: false,
164
+ }
165
+ for (let index = 0; index < argv.length; index += 1) {
166
+ const token = argv[index]
167
+ if (token === '--help' || token === '-h') {
168
+ options.help = true
169
+ continue
170
+ }
171
+ const equals = token.indexOf('=')
172
+ const key = equals === -1 ? token : token.slice(0, equals)
173
+ const value = () => {
174
+ if (equals !== -1) return token.slice(equals + 1)
175
+ const next = argv[index + 1]
176
+ if (next === undefined) throw new Error(`${key} needs a value`)
177
+ index += 1
178
+ return next
179
+ }
180
+ switch (key) {
181
+ case '--url': options.url = value(); break
182
+ case '--expect': options.expect.push(...parseExpected(value())); break
183
+ case '--out': options.out = value(); break
184
+ case '--screenshot': options.screenshot = value(); break
185
+ case '--login-file': options.loginFile = value(); break
186
+ case '--executable': options.executable = value(); break
187
+ case '--timeout': {
188
+ const parsed = Number(value())
189
+ if (!Number.isFinite(parsed) || parsed < 1000) throw new Error('--timeout must be >= 1000 ms')
190
+ options.timeout = parsed
191
+ break
192
+ }
193
+ default: throw new Error(`unknown argument ${token}`)
194
+ }
195
+ }
196
+ if (options.help) return options
197
+ if (options.url === null) throw new Error('--url is required')
198
+ if (options.out === null) throw new Error('--out is required')
199
+ options.expect = [...new Set(options.expect)]
200
+ if (options.screenshot === null) options.screenshot = options.out.replace(/\.json$/u, '') + '.png'
201
+ return options
202
+ }
203
+
204
+ /** Create the parent directory of a file path. */
205
+ function ensureParent(file) {
206
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true })
207
+ }
208
+
209
+ /** Write the result JSON. */
210
+ function writeResult(file, result) {
211
+ ensureParent(file)
212
+ fs.writeFileSync(file, JSON.stringify(result, null, 2) + '\n')
213
+ }
214
+
215
+ /** Redact a token-bearing URL for logs and JSON. */
216
+ function redactUrl(value) {
217
+ if (typeof value !== 'string') return value
218
+ return value.replace(/([?&]token=)[^&\s]+/gu, '$1<redacted>')
219
+ }
220
+
221
+ /** Truncate a string for diagnostics. */
222
+ function truncate(value, limit) {
223
+ if (typeof value !== 'string') return ''
224
+ return value.length > limit ? value.slice(0, limit) + '…' : value
225
+ }
226
+
227
+ /**
228
+ * Decide whether a request is a same-origin JavaScript request worth reporting.
229
+ * @param {string} url - request URL.
230
+ * @param {string} origin - page origin.
231
+ * @param {string} resourceType - Playwright resource type.
232
+ * @returns {boolean} true for same-origin JavaScript.
233
+ */
234
+ function isSameOriginJs(url, origin, resourceType) {
235
+ if (resourceType !== 'script') return false
236
+ try {
237
+ return new URL(url).origin === origin
238
+ } catch {
239
+ return false
240
+ }
241
+ }
242
+
243
+ /** Run the browser probe and return the result object. */
244
+ async function probe(options) {
245
+ const started = Date.now()
246
+ const expected = options.expect
247
+ const result = {
248
+ schema: SCHEMA,
249
+ ok: false,
250
+ expected,
251
+ expectedEntries: Object.fromEntries(expected.map(id => [id, false])),
252
+ bootGraph: 'missing',
253
+ bootRev: null,
254
+ bootIds: [],
255
+ loadedEntries: [],
256
+ notLoadedEntries: [],
257
+ missingEntries: [...expected],
258
+ entries: [],
259
+ consoleErrors: [],
260
+ consoleWarnings: [],
261
+ pageErrors: [],
262
+ failedRequests: [],
263
+ login: null,
264
+ loginError: null,
265
+ navigationStatus: null,
266
+ finalUrl: null,
267
+ title: null,
268
+ rootChildren: 0,
269
+ bootPageVisible: true,
270
+ bootPageText: '',
271
+ bodyTextLength: 0,
272
+ bodyText: '',
273
+ screenshot: path.resolve(options.screenshot),
274
+ elapsedMs: 0,
275
+ error: null,
276
+ }
277
+
278
+ let browser
279
+ let page
280
+ try {
281
+ const { chromium } = await import('playwright')
282
+ const launchOptions = {
283
+ headless: true,
284
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
285
+ }
286
+ if (options.executable !== null) launchOptions.executablePath = options.executable
287
+ browser = await chromium.launch(launchOptions)
288
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 } })
289
+ const origin = new URL(options.url).origin
290
+
291
+ // A gated app (dsh-ui-auth) answers the token URL with a login redirect
292
+ // before the host token exchange. Log in first so the page reaches the real
293
+ // shell; the cookie jar is shared by context.request and the browser page.
294
+ if (options.loginFile !== null) {
295
+ try {
296
+ const credentials = parseCredentialFile(fs.readFileSync(options.loginFile, 'utf8'))
297
+ const response = await context.request.post(new URL(LOGIN_PATH, origin).href, {
298
+ data: { username: credentials.username, password: credentials.password, totp: '' },
299
+ failOnStatusCode: false,
300
+ timeout: 30_000,
301
+ })
302
+ const body = await response.text()
303
+ let parsed
304
+ try { parsed = JSON.parse(body) } catch { parsed = null }
305
+ result.login = {
306
+ path: LOGIN_PATH,
307
+ status: response.status(),
308
+ ok: response.status() === 200 && parsed?.ok === true,
309
+ }
310
+ if (!result.login.ok) {
311
+ result.loginError = `POST ${LOGIN_PATH} returned ${response.status()}: ${truncate(body, 300)}`
312
+ }
313
+ } catch (error) {
314
+ result.loginError = `login failed: ${error instanceof Error ? error.message : String(error)}`
315
+ }
316
+ }
317
+
318
+ page = await context.newPage()
319
+ page.on('console', message => {
320
+ const type = message.type()
321
+ if (type !== 'error' && type !== 'warning') return
322
+ const entry = {
323
+ type,
324
+ text: truncate(message.text(), 4000),
325
+ url: message.location().url ?? null,
326
+ lineNumber: message.location().lineNumber ?? null,
327
+ }
328
+ if (type === 'error') result.consoleErrors.push(entry)
329
+ else result.consoleWarnings.push(entry)
330
+ })
331
+ page.on('pageerror', error => {
332
+ result.pageErrors.push({
333
+ message: truncate(error instanceof Error ? error.message : String(error), 4000),
334
+ stack: truncate(error instanceof Error ? (error.stack ?? '') : '', 6000),
335
+ })
336
+ })
337
+ page.on('requestfailed', request => {
338
+ if (!isSameOriginJs(request.url(), origin, request.resourceType())) return
339
+ result.failedRequests.push({
340
+ url: request.url(),
341
+ resourceType: request.resourceType(),
342
+ status: null,
343
+ failure: truncate(request.failure()?.errorText ?? 'request failed', 500),
344
+ })
345
+ })
346
+ page.on('response', response => {
347
+ if (response.status() < 400) return
348
+ const request = response.request()
349
+ if (!isSameOriginJs(response.url(), origin, request.resourceType())) return
350
+ if (result.failedRequests.some(item => item.url === response.url())) return
351
+ result.failedRequests.push({
352
+ url: response.url(),
353
+ resourceType: request.resourceType(),
354
+ status: response.status(),
355
+ failure: null,
356
+ })
357
+ })
358
+
359
+ const navigation = await page.goto(options.url, {
360
+ waitUntil: 'domcontentloaded',
361
+ timeout: Math.min(options.timeout, 60_000),
362
+ })
363
+ result.navigationStatus = navigation?.status() ?? null
364
+
365
+ let bootWire
366
+ try {
367
+ await page.waitForFunction(
368
+ () => globalThis.__DSH_BOOT__ !== undefined && globalThis.__DSH_BOOT__ !== null,
369
+ null,
370
+ { timeout: Math.min(options.timeout, GRAPH_WAIT_MS) },
371
+ )
372
+ // The waitForFunction handle is the boolean predicate, not the graph.
373
+ bootWire = await page.evaluate(() => globalThis.__DSH_BOOT__)
374
+ } catch {
375
+ bootWire = undefined
376
+ }
377
+ const boot = summarizeBootGraph(bootWire, expected, [])
378
+ result.bootGraph = boot.bootGraph
379
+ result.bootRev = boot.bootRev
380
+ result.bootIds = boot.bootIds
381
+ result.missingEntries = boot.missingEntries
382
+ result.expectedEntries = Object.fromEntries(boot.rows.map(row => [row.id, row.booted]))
383
+
384
+ if (boot.bootGraph === 'ok') {
385
+ const remaining = Math.max(5_000, options.timeout - (Date.now() - started))
386
+ try {
387
+ await page.waitForFunction(
388
+ () => {
389
+ const root = document.querySelector('#root')
390
+ return root !== null && root.children.length > 0
391
+ && root.querySelector('[data-dsh-boot]') === null
392
+ },
393
+ null,
394
+ { timeout: remaining },
395
+ )
396
+ } catch {
397
+ // The boot page may still be visible; the snapshot below captures its text.
398
+ }
399
+ }
400
+
401
+ const snapshot = await page.evaluate(() => {
402
+ const root = document.querySelector('#root')
403
+ const bootPage = root?.querySelector('[data-dsh-boot]') ?? null
404
+ const bodyText = document.body !== null ? (document.body.innerText ?? '') : ''
405
+ return {
406
+ rootChildren: root !== null ? root.children.length : 0,
407
+ bootPageVisible: bootPage !== null,
408
+ bootPageText: (bootPage !== null ? bootPage.textContent : '') ?? '',
409
+ bodyText,
410
+ bodyTextLength: bodyText.length,
411
+ resources: performance.getEntriesByType('resource').map(entry => entry.name),
412
+ }
413
+ })
414
+
415
+ const projection = summarizeBootGraph(bootWire, expected, snapshot.resources)
416
+ result.entries = projection.rows
417
+ result.loadedEntries = projection.loadedEntries
418
+ result.notLoadedEntries = projection.rows.filter(row => row.booted && !row.loaded).map(row => row.id)
419
+ result.missingEntries = projection.missingEntries
420
+ result.expectedEntries = Object.fromEntries(projection.rows.map(row => [row.id, row.booted]))
421
+ result.finalUrl = redactUrl(page.url())
422
+ result.title = await page.title()
423
+ result.rootChildren = snapshot.rootChildren
424
+ result.bootPageVisible = snapshot.bootPageVisible
425
+ result.bootPageText = truncate(snapshot.bootPageText, 4000)
426
+ result.bodyTextLength = snapshot.bodyTextLength
427
+ result.bodyText = truncate(snapshot.bodyText, 2000)
428
+
429
+ result.ok = projection.bootGraph === 'ok'
430
+ && projection.missingEntries.length === 0
431
+ && result.notLoadedEntries.length === 0
432
+ && result.consoleErrors.length === 0
433
+ && result.pageErrors.length === 0
434
+ && result.failedRequests.length === 0
435
+ && result.rootChildren > 0
436
+ && result.bootPageVisible === false
437
+ && result.loginError === null
438
+ } catch (error) {
439
+ result.error = error instanceof Error ? (error.stack ?? error.message) : String(error)
440
+ } finally {
441
+ if (page !== undefined) {
442
+ try {
443
+ ensureParent(options.screenshot)
444
+ await page.screenshot({ path: options.screenshot, fullPage: true, timeout: 15_000 })
445
+ } catch (error) {
446
+ result.screenshotError = error instanceof Error ? error.message : String(error)
447
+ }
448
+ }
449
+ result.elapsedMs = Date.now() - started
450
+ result.screenshot = path.resolve(options.screenshot)
451
+ try {
452
+ if (browser !== undefined) await browser.close()
453
+ } catch {
454
+ // A failed close must not hide the probe result.
455
+ }
456
+ if (result.error !== null) result.ok = false
457
+ if (typeof result.error === 'string') result.error = redactUrl(result.error)
458
+ if (typeof result.loginError === 'string') result.loginError = redactUrl(result.loginError)
459
+ result.url = redactUrl(options.url)
460
+ result.generatedAt = new Date().toISOString()
461
+ writeResult(options.out, result)
462
+ }
463
+ return result
464
+ }
465
+
466
+ /** CLI entry point. */
467
+ async function main() {
468
+ let options
469
+ try {
470
+ options = parseArgs(process.argv.slice(2))
471
+ } catch (error) {
472
+ console.error(`web-probe: ${error instanceof Error ? error.message : String(error)}`)
473
+ process.exitCode = 2
474
+ return
475
+ }
476
+ if (options.help) {
477
+ console.log('usage: node web-probe.mjs --url <token-url> --out <json> [--expect pkg1,pkg2]')
478
+ console.log(' [--screenshot <png>] [--login-file <file>] [--executable <path>] [--timeout <ms>]')
479
+ return
480
+ }
481
+ const result = await probe(options)
482
+ if (!result.ok) {
483
+ const reasons = []
484
+ if (result.error) reasons.push(result.error.split('\n')[0])
485
+ if (result.bootGraph !== 'ok') reasons.push(`boot graph ${result.bootGraph}`)
486
+ if (result.missingEntries.length > 0) reasons.push(`missing entries: ${result.missingEntries.join(', ')}`)
487
+ if (result.notLoadedEntries.length > 0) reasons.push(`no resource observed: ${result.notLoadedEntries.join(', ')}`)
488
+ if (result.failedRequests.length > 0) reasons.push(`${result.failedRequests.length} failed script request(s)`)
489
+ if (result.consoleErrors.length > 0) reasons.push(`${result.consoleErrors.length} console error(s)`)
490
+ if (result.pageErrors.length > 0) reasons.push(`${result.pageErrors.length} page error(s)`)
491
+ if (result.rootChildren === 0) reasons.push('#root has no children')
492
+ if (result.bootPageVisible) reasons.push('boot page still visible')
493
+ if (result.loginError !== null) reasons.push(`login: ${result.loginError}`)
494
+ console.error(`web-probe: FAIL (${reasons.join('; ')})`)
495
+ console.error(`web-probe: result ${path.resolve(options.out)}`)
496
+ process.exitCode = 1
497
+ return
498
+ }
499
+ console.log(`web-probe: PASS ${result.expected.join(', ')} (${result.elapsedMs} ms, ${result.loadedEntries.length}/${result.expected.length} resource(s) observed)`)
500
+ }
501
+
502
+ const invoked = process.argv[1] !== undefined
503
+ && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
504
+ if (invoked) await main()