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.
- package/40-checker/README.md +251 -0
- package/40-checker/bin/dsh-levels.mjs +15 -0
- package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
- package/40-checker/fixtures/bad/index.mjs +12 -0
- package/40-checker/fixtures/bad/package.json +15 -0
- package/40-checker/fixtures/bad-manifest/index.js +1 -0
- package/40-checker/fixtures/bad-manifest/package.json +5 -0
- package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
- package/40-checker/fixtures/bundle/package.json +15 -0
- package/40-checker/fixtures/bundle/server.mjs +7 -0
- package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
- package/40-checker/fixtures/bundle-v0/package.json +21 -0
- package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
- package/40-checker/fixtures/client-missing/client.js +1 -0
- package/40-checker/fixtures/client-missing/index.js +1 -0
- package/40-checker/fixtures/client-missing/package.json +6 -0
- package/40-checker/fixtures/flow-config/index.js +9 -0
- package/40-checker/fixtures/flow-config/package.json +10 -0
- package/40-checker/fixtures/fs-write/index.mjs +7 -0
- package/40-checker/fixtures/fs-write/package.json +10 -0
- package/40-checker/fixtures/good/index.mjs +10 -0
- package/40-checker/fixtures/good/package.json +19 -0
- package/40-checker/fixtures/hang/index.mjs +5 -0
- package/40-checker/fixtures/hang/package.json +10 -0
- package/40-checker/fixtures/known-answer/expected.json +18 -0
- package/40-checker/fixtures/market-index-real.json +18 -0
- package/40-checker/fixtures/market-index.json +18 -0
- package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
- package/40-checker/fixtures/negative/no-entry/package.json +1 -0
- package/40-checker/fixtures/negative/no-patch/package.json +1 -0
- package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
- package/40-checker/fixtures/negative/root-missing/package.json +1 -0
- package/40-checker/fixtures/negative/workspace/package.json +1 -0
- package/40-checker/package.json +22 -0
- package/40-checker/samples/README.md +8 -0
- package/40-checker/samples/entry18.json +139 -0
- package/40-checker/samples/frame.json +485 -0
- package/40-checker/scripts/apply-overlay.mjs +59 -0
- package/40-checker/scripts/attestation-check.mjs +151 -0
- package/40-checker/scripts/bundle-smoke.mjs +126 -0
- package/40-checker/scripts/claim-check.mjs +145 -0
- package/40-checker/scripts/clause-map.mjs +94 -0
- package/40-checker/scripts/collect-fixtures.mjs +44 -0
- package/40-checker/scripts/dashboard.mjs +85 -0
- package/40-checker/scripts/dco-check.mjs +147 -0
- package/40-checker/scripts/dependabot-check.mjs +40 -0
- package/40-checker/scripts/doc-commands-check.mjs +92 -0
- package/40-checker/scripts/entry-scan.mjs +190 -0
- package/40-checker/scripts/entry-validate.mjs +291 -0
- package/40-checker/scripts/entry18-attribution.mjs +66 -0
- package/40-checker/scripts/entry18-ci.mjs +685 -0
- package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
- package/40-checker/scripts/entry18-web.mjs +307 -0
- package/40-checker/scripts/evidence-chain.mjs +100 -0
- package/40-checker/scripts/flow-index.mjs +82 -0
- package/40-checker/scripts/flow-sample-ci.mjs +529 -0
- package/40-checker/scripts/governance-check.mjs +210 -0
- package/40-checker/scripts/host-farm.mjs +165 -0
- package/40-checker/scripts/journey-config-observe.mjs +109 -0
- package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
- package/40-checker/scripts/journey-runtime.mjs +178 -0
- package/40-checker/scripts/journey.mjs +76 -0
- package/40-checker/scripts/label-drift.mjs +74 -0
- package/40-checker/scripts/label-sync.mjs +87 -0
- package/40-checker/scripts/label-verify.mjs +90 -0
- package/40-checker/scripts/landlock-exec.c +101 -0
- package/40-checker/scripts/level-audit.mjs +336 -0
- package/40-checker/scripts/m1-sidecar.mjs +126 -0
- package/40-checker/scripts/m2-runner.mjs +213 -0
- package/40-checker/scripts/round-robin.mjs +88 -0
- package/40-checker/scripts/sample-frame.mjs +72 -0
- package/40-checker/scripts/sample20-ci.mjs +25 -0
- package/40-checker/scripts/sample60-ci.mjs +25 -0
- package/40-checker/scripts/stale-check.mjs +59 -0
- package/40-checker/scripts/surveillance.mjs +88 -0
- package/40-checker/scripts/web-probe.mjs +504 -0
- package/40-checker/src/cli.mjs +300 -0
- package/40-checker/src/client-shape.mjs +37 -0
- package/40-checker/src/declarations.mjs +242 -0
- package/40-checker/src/index.mjs +8 -0
- package/40-checker/src/install-spec.mjs +67 -0
- package/40-checker/src/label.mjs +130 -0
- package/40-checker/src/manifest.mjs +381 -0
- package/40-checker/src/permissions.mjs +60 -0
- package/40-checker/src/report.mjs +204 -0
- package/40-checker/src/result.mjs +60 -0
- package/40-checker/src/runtime.mjs +299 -0
- package/40-checker/src/semver.mjs +194 -0
- package/40-checker/src/util.mjs +148 -0
- package/40-checker/test/attestation.test.mjs +59 -0
- package/40-checker/test/checks.test.mjs +172 -0
- package/40-checker/test/claim.test.mjs +28 -0
- package/40-checker/test/client-shape.test.mjs +35 -0
- package/40-checker/test/dco.test.mjs +51 -0
- package/40-checker/test/entry-validate.test.mjs +172 -0
- package/40-checker/test/governance.test.mjs +76 -0
- package/40-checker/test/label.test.mjs +97 -0
- package/40-checker/test/level-audit.test.mjs +99 -0
- package/40-checker/test/levels.test.mjs +31 -0
- package/40-checker/test/ops.test.mjs +117 -0
- package/40-checker/test/permissions.test.mjs +40 -0
- package/40-checker/test/semver.test.mjs +32 -0
- package/40-checker/test/verify.test.mjs +57 -0
- package/CHANGELOG.md +24 -0
- package/LICENSE +28 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-CC-BY +23 -0
- package/NOTICE +14 -0
- package/NOTICE.md +11 -0
- package/README.md +146 -3
- package/bin/dsh-levels.mjs +4 -0
- package/bin/dsh-standards.mjs +19 -0
- package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
- package/docs/DEVELOPER-JOURNEY.md +40 -0
- package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
- package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
- package/docs/NAMING-FALLBACK.md +11 -0
- package/docs/fork-policy.md +12 -0
- package/docs/full-test-plan-2026-11-02.md +207 -0
- package/docs/incident.md +37 -0
- package/docs/onboarding.md +27 -0
- package/docs/privacy.md +12 -0
- package/docs/release.md +16 -0
- package/docs/scope-security.md +62 -0
- package/docs/status-taxonomy.md +54 -0
- package/docs/sunset.md +44 -0
- package/docs/translating.md +12 -0
- package/package.json +58 -12
- package/spec/README.md +27 -0
- package/spec/boost/README.md +8 -0
- package/spec/clauses.json +253 -0
- package/spec/entry/README.md +9 -0
- package/spec/flow/README.md +8 -0
- package/spec/levels.json +371 -0
- package/spec/levels.schema.json +45 -0
- package/spec/reference-suite.json +35 -0
- package/spec/schemas/attestation.schema.json +284 -0
- package/spec/schemas/evidence.schema.json +331 -0
- 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()
|