rvlib-shipkit 0.35.8 → 0.36.0
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/CHANGELOG.md +17 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Fleet.ts +116 -0
- package/src/cli/cmd_Prose.ts +65 -0
- package/src/cli/cmd_Smoke.ts +42 -0
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli/cmd_Units.ts +82 -0
- package/src/cli.ts +8 -0
- package/src/core/check-suite.ts +28 -5
- package/src/core/run-check.ts +12 -1
- package/src/core/run-ci.ts +12 -2
- package/src/core/write-status.ts +5 -1
- package/src/fleet/scan-fleet.ts +142 -0
- package/src/git-bootstrap.ts +11 -5
- package/src/ledger/gate-units.ts +80 -27
- package/src/ledger/store.ts +7 -2
- package/src/ledger/unit-table.ts +105 -0
- package/src/prose/run-prose.ts +146 -0
- package/src/prose/ste.ts +436 -0
- package/src/repo-config.ts +35 -1
- package/src/rules/SK014-prose-ste.ts +50 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +4 -1
- package/src/smoke/run-smoke.ts +403 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// `shipkit smoke` — a READ ONLY browser pass over a deployed web app: log in
|
|
2
|
+
// through the app's own login page, visit every declared route, count what
|
|
3
|
+
// rendered, collect console errors and failed requests, screenshot each
|
|
4
|
+
// route, and read the host's quota headers when it sends them.
|
|
5
|
+
//
|
|
6
|
+
// It never submits a business form, never clicks a destructive control and
|
|
7
|
+
// never writes. It runs against whatever the base url points at, which is
|
|
8
|
+
// usually PRODUCTION, so the read only rule is a hard constraint.
|
|
9
|
+
//
|
|
10
|
+
// Playwright is NOT a dependency of this package: a toolkit every repo
|
|
11
|
+
// installs must not carry a browser. It is resolved from the CONSUMER repo at
|
|
12
|
+
// run time, and a repo that never installed it gets one loud line naming the
|
|
13
|
+
// two commands. Everything that does not need a browser (config validation,
|
|
14
|
+
// the route verdict, the report) is pure and tested; the browser half is
|
|
15
|
+
// exercised in the field, by the run itself.
|
|
16
|
+
import { mkdirSync } from 'fs'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
|
|
19
|
+
export type SmokeRoute = {
|
|
20
|
+
/** the path to visit, always leading `/` */
|
|
21
|
+
path: string
|
|
22
|
+
/** a selector that must match at least once once the route settled
|
|
23
|
+
* (`tbody tr` for a listing). Absent: the route only has to render text. */
|
|
24
|
+
waitFor?: string
|
|
25
|
+
/** a link selector (`a[href^="/orders/"]`): the run follows the FIRST
|
|
26
|
+
* match's href after this route and judges the landed page as its own
|
|
27
|
+
* row. How a detail page with a real id gets visited without knowing one. */
|
|
28
|
+
open?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SmokeLogin = {
|
|
32
|
+
/** where the login form lives. Default `/login`, the house router seed. */
|
|
33
|
+
path?: string
|
|
34
|
+
/** the identity typed into the login form. One of `email` / `emailSecret`. */
|
|
35
|
+
email?: string
|
|
36
|
+
/** the secret whose ACCOUNT field is the identity (read through the repo's `credsReadCommand`) */
|
|
37
|
+
emailSecret?: string
|
|
38
|
+
/** the secret whose value is the password, a NAME the repo's `credsReadCommand` resolves. Never argv, never env. */
|
|
39
|
+
passwordSecret: string
|
|
40
|
+
/** how to find the three controls: a CSS selector (starts with `#`, `.`,
|
|
41
|
+
* `[` or a tag) or a LABEL. A label finds the field by its `<label>` text
|
|
42
|
+
* and the submit by its button name. */
|
|
43
|
+
form: { email: string; password: string; submit: string }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SmokeConfig = {
|
|
47
|
+
/** the url the run visits. `--base` overrides it for one run. */
|
|
48
|
+
base: string
|
|
49
|
+
/** the routes to visit, in order. A bare string is `{ path }`. */
|
|
50
|
+
routes: (string | SmokeRoute)[]
|
|
51
|
+
/** absent: the run visits the routes anonymously */
|
|
52
|
+
login?: SmokeLogin
|
|
53
|
+
/** pause between routes, ms. A route change fires a burst of requests, and
|
|
54
|
+
* a host with a CONCURRENT cap sheds some of them without CORS headers,
|
|
55
|
+
* which then reads as a broken app. 3000 is measured against PocketHost's
|
|
56
|
+
* 15 concurrent per IP: 0 reported 8 of 12 routes, 3000 reports 12 of 12. */
|
|
57
|
+
paceMs?: number
|
|
58
|
+
/** where the screenshots land, repo relative */
|
|
59
|
+
shots?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
|
|
63
|
+
const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
|
|
64
|
+
const FORM_KEYS = new Set(['email', 'password', 'submit'])
|
|
65
|
+
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
|
|
66
|
+
export const DEFAULT_LOGIN_PATH = '/login'
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_PACE_MS = 3000
|
|
69
|
+
export const DEFAULT_SHOTS = 'tmp/smoke'
|
|
70
|
+
|
|
71
|
+
/** the whole shape, refused loud on the first thing that is not so. A typo
|
|
72
|
+
* here would otherwise land as a run that visits nothing and exits 0. */
|
|
73
|
+
export function validateSmokeConfig(c: unknown): SmokeConfig {
|
|
74
|
+
if (c == null || typeof c !== 'object') throw new Error('defineRepo: smoke must be an object { base, routes, login? }')
|
|
75
|
+
const s = c as Record<string, unknown>
|
|
76
|
+
for (const k of Object.keys(s)) if (!SMOKE_KEYS.has(k)) throw new Error(`defineRepo: smoke.${k} is not a smoke field (fields: ${[...SMOKE_KEYS].join(', ')})`)
|
|
77
|
+
if (typeof s['base'] !== 'string' || !/^https?:\/\//.test(s['base'])) throw new Error(`defineRepo: smoke.base must be an http(s) url`)
|
|
78
|
+
if (!Array.isArray(s['routes']) || s['routes'].length === 0) throw new Error(`defineRepo: smoke.routes must list at least one route`)
|
|
79
|
+
for (const r of s['routes'] as unknown[]) {
|
|
80
|
+
const path = typeof r === 'string' ? r : r != null && typeof r === 'object' ? (r as Record<string, unknown>)['path'] : undefined
|
|
81
|
+
if (typeof path !== 'string' || !path.startsWith('/')) throw new Error(`defineRepo: smoke.routes entry ${JSON.stringify(r)} needs a path starting with '/'`)
|
|
82
|
+
if (typeof r === 'object' && r != null) {
|
|
83
|
+
for (const k of Object.keys(r)) if (!ROUTE_KEYS.has(k)) throw new Error(`defineRepo: smoke.routes['${path}'].${k} is not a route field (fields: ${[...ROUTE_KEYS].join(', ')})`)
|
|
84
|
+
for (const k of ['waitFor', 'open'] as const) {
|
|
85
|
+
const v = (r as Record<string, unknown>)[k]
|
|
86
|
+
if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (s['login'] != null) {
|
|
91
|
+
const l = s['login'] as Record<string, unknown>
|
|
92
|
+
if (typeof l !== 'object') throw new Error('defineRepo: smoke.login must be an object')
|
|
93
|
+
for (const k of Object.keys(l)) if (!LOGIN_KEYS.has(k)) throw new Error(`defineRepo: smoke.login.${k} is not a login field (fields: ${[...LOGIN_KEYS].join(', ')})`)
|
|
94
|
+
if (l['path'] != null && (typeof l['path'] !== 'string' || !l['path'].startsWith('/'))) throw new Error(`defineRepo: smoke.login.path must start with '/'`)
|
|
95
|
+
const hasEmail = typeof l['email'] === 'string' && l['email'] !== ''
|
|
96
|
+
const hasEmailSecret = typeof l['emailSecret'] === 'string' && l['emailSecret'] !== ''
|
|
97
|
+
if (hasEmail === hasEmailSecret) throw new Error('defineRepo: smoke.login needs exactly one of email / emailSecret')
|
|
98
|
+
if (typeof l['passwordSecret'] !== 'string' || l['passwordSecret'] === '') throw new Error('defineRepo: smoke.login.passwordSecret is required (a secret name, never the value)')
|
|
99
|
+
// a password typed into config is the leak this field exists to prevent
|
|
100
|
+
if (!/^[\w./-]+$/.test(l['passwordSecret'])) throw new Error('defineRepo: smoke.login.passwordSecret must be a secret NAME (letters, digits, / . _ -), not a value')
|
|
101
|
+
const f = l['form'] as Record<string, unknown> | undefined
|
|
102
|
+
if (f == null || typeof f !== 'object') throw new Error('defineRepo: smoke.login.form { email, password, submit } is required')
|
|
103
|
+
for (const k of Object.keys(f)) if (!FORM_KEYS.has(k)) throw new Error(`defineRepo: smoke.login.form.${k} is not a form field (fields: email, password, submit)`)
|
|
104
|
+
for (const k of FORM_KEYS) if (typeof f[k] !== 'string' || f[k] === '') throw new Error(`defineRepo: smoke.login.form.${k} is required (a selector or a label)`)
|
|
105
|
+
}
|
|
106
|
+
if (s['paceMs'] != null && (typeof s['paceMs'] !== 'number' || s['paceMs'] < 0)) throw new Error('defineRepo: smoke.paceMs must be a number of milliseconds')
|
|
107
|
+
if (s['shots'] != null && (typeof s['shots'] !== 'string' || s['shots'] === '')) throw new Error('defineRepo: smoke.shots must be a repo relative dir')
|
|
108
|
+
return c as SmokeConfig
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const normalizeRoutes = (routes: (string | SmokeRoute)[]): SmokeRoute[] =>
|
|
112
|
+
routes.map((r) => (typeof r === 'string' ? { path: r } : r))
|
|
113
|
+
|
|
114
|
+
/** `--routes /a,/b` → the declared entries for those paths, or bare paths for
|
|
115
|
+
* routes the config never declared (a one-off visit is still a visit) */
|
|
116
|
+
export function selectRoutes(declared: SmokeRoute[], flag: string | undefined): SmokeRoute[] {
|
|
117
|
+
if (flag == null || flag.trim() === '') return declared
|
|
118
|
+
const byPath = new Map(declared.map((r) => [r.path, r]))
|
|
119
|
+
return flag.split(',').map((p) => p.trim()).filter((p) => p !== '').map((p) => byPath.get(p) ?? { path: p })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** the screenshot file name of a route */
|
|
123
|
+
export const routeSlug = (path: string): string => (path === '/' ? 'root' : path.replace(/^\//, '').replace(/[^\w.-]+/g, '-'))
|
|
124
|
+
|
|
125
|
+
export type Problem = { route: string; kind: string; detail: string }
|
|
126
|
+
|
|
127
|
+
export type RouteReport = {
|
|
128
|
+
route: string
|
|
129
|
+
ok: boolean
|
|
130
|
+
/** visible text length once the route settled */
|
|
131
|
+
chars: number
|
|
132
|
+
/** `waitFor` matches (0 when the route declares none) */
|
|
133
|
+
matches: number
|
|
134
|
+
/** console, page and network problems collected while on this route */
|
|
135
|
+
problems: number
|
|
136
|
+
note: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type Quota = { remaining: number; limit: number; resetAt: number }
|
|
140
|
+
|
|
141
|
+
export type SmokeReport = {
|
|
142
|
+
base: string
|
|
143
|
+
identity: string | null
|
|
144
|
+
routes: RouteReport[]
|
|
145
|
+
problems: Problem[]
|
|
146
|
+
quota: Quota | null
|
|
147
|
+
shots: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** the verdict of one route, pure: a blank shell is a failure even when
|
|
151
|
+
* nothing threw, an empty `waitFor` on a listing is exactly how a broken
|
|
152
|
+
* migration looks, and a page that scrolls sideways is a layout defect only
|
|
153
|
+
* a real browser can see (happy-dom does no layout). */
|
|
154
|
+
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
|
|
155
|
+
const empty = r.chars < 40
|
|
156
|
+
const noMatch = r.waitFor != null && r.matches === 0
|
|
157
|
+
const sideways = r.overflowPx > 1
|
|
158
|
+
const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
|
|
159
|
+
return { route: r.route, ok: !empty && !noMatch && !sideways && r.problems === 0, chars: r.chars, matches: r.matches, problems: r.problems, note }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** a failure that only exists because the backend refused to answer: a host
|
|
163
|
+
* over its quota drops the CORS headers, so the browser reports a CORS error */
|
|
164
|
+
export const THROTTLE_SHAPE = /ERR_FAILED|blocked by CORS policy|Failed to load resource/
|
|
165
|
+
|
|
166
|
+
// noise that is not a defect: a favicon 404, a devtools hint, a Vite HMR ping
|
|
167
|
+
const IGNORE = [/favicon/i, /Download the React DevTools/i, /\[vite\]/i, /React Router Future Flag/i]
|
|
168
|
+
export const isNoise = (s: string): boolean => IGNORE.some((re) => re.test(s))
|
|
169
|
+
|
|
170
|
+
/** the wire lines of a finished run */
|
|
171
|
+
export function renderReport(rep: SmokeReport, opts: { paceMs: number }): string[] {
|
|
172
|
+
const out: string[] = []
|
|
173
|
+
out.push('route'.padEnd(42) + 'chars matches status')
|
|
174
|
+
for (const r of rep.routes) out.push(r.route.padEnd(42) + String(r.chars).padEnd(8) + String(r.matches).padEnd(9) + (r.ok ? 'ok' : `FAIL ${r.note}`))
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
const unique = rep.problems.filter((p) => {
|
|
177
|
+
const key = `${p.route}|${p.kind}|${p.detail}`
|
|
178
|
+
if (seen.has(key)) return false
|
|
179
|
+
seen.add(key)
|
|
180
|
+
return true
|
|
181
|
+
})
|
|
182
|
+
if (unique.length > 0) {
|
|
183
|
+
out.push('')
|
|
184
|
+
out.push(`${unique.length} console/network problem(s):`)
|
|
185
|
+
for (const p of unique) out.push(` [${p.route}] ${p.kind}: ${p.detail}`)
|
|
186
|
+
}
|
|
187
|
+
if (rep.quota != null) {
|
|
188
|
+
const mins = rep.quota.resetAt > 0 ? Math.max(0, Math.round((rep.quota.resetAt * 1000 - Date.now()) / 60_000)) : 0
|
|
189
|
+
out.push('')
|
|
190
|
+
out.push(`quota: ${rep.quota.remaining}/${rep.quota.limit} requests left for this IP this hour, resets in ~${mins} min`)
|
|
191
|
+
}
|
|
192
|
+
const throttled = rep.problems.filter((p) => THROTTLE_SHAPE.test(p.detail)).length
|
|
193
|
+
if (throttled > 0) {
|
|
194
|
+
// two different caps, and naming the wrong one sends the reader to the
|
|
195
|
+
// wrong fix: the hourly quota needs waiting out, the concurrent cap
|
|
196
|
+
// needs pacing. The remaining count tells them apart.
|
|
197
|
+
const hourlyExhausted = rep.quota != null && rep.quota.remaining < 50
|
|
198
|
+
out.push('')
|
|
199
|
+
out.push(`WARN throttled: ${throttled} of the problems above are the backend refusing to answer, not the app (a shed request carries no CORS headers)`)
|
|
200
|
+
out.push(hourlyExhausted ? ' the HOURLY quota is spent: wait for the reset above, then re-run' : ` the hourly quota is healthy, so this is the CONCURRENT cap: raise smoke.paceMs (currently ${opts.paceMs}) and re-run before believing any of them`)
|
|
201
|
+
}
|
|
202
|
+
const failed = rep.routes.filter((r) => !r.ok).length
|
|
203
|
+
out.push('')
|
|
204
|
+
out.push(`${failed === 0 ? 'ok:' : 'FAIL:'} ${rep.routes.length - failed}/${rep.routes.length} routes ok${rep.identity != null ? ` as ${rep.identity}` : ''}. Screenshots in ${rep.shots}/`)
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** the process exit code: the number of failed routes, held inside the byte a
|
|
209
|
+
* shell can read (a count above it would wrap to a false green) */
|
|
210
|
+
export const exitCodeFor = (rep: SmokeReport): number => Math.min(125, rep.routes.filter((r) => !r.ok).length)
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// the browser half: everything under here needs playwright in the consumer
|
|
214
|
+
|
|
215
|
+
/** the argv of a `credsReadCommand` template for one secret: split on
|
|
216
|
+
* whitespace, placeholders substituted, never a shell */
|
|
217
|
+
export function secretArgv(template: string, name: string, field: 'get' | 'account'): string[] {
|
|
218
|
+
return template.trim().split(/\s+/).map((w) => w.replaceAll('{name}', name).replaceAll('{field}', field))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** a secret read through the repo's declared command, never argv and never
|
|
222
|
+
* env. The ACCOUNT field of a secret carries the identity, the value the
|
|
223
|
+
* password. */
|
|
224
|
+
export function readSecret(template: string, name: string, field: 'get' | 'account' = 'get'): string {
|
|
225
|
+
const argv = secretArgv(template, name, field)
|
|
226
|
+
const bin = argv[0] ?? ''
|
|
227
|
+
if (bin === '' || Bun.which(bin) == null) throw new Error(`FAIL credsReadCommand names '${bin}', which is not on PATH`)
|
|
228
|
+
const r = Bun.spawnSync(argv)
|
|
229
|
+
const v = r.stdout.toString().trim()
|
|
230
|
+
if (r.exitCode !== 0 || v === '') throw new Error(`FAIL \`${argv.join(' ')}\` answered nothing — store the secret under that name first`)
|
|
231
|
+
return v
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** the smallest slice of playwright this module touches, so the package
|
|
235
|
+
* types nothing it does not ship */
|
|
236
|
+
type Locator = { fill(v: string): Promise<void>; click(): Promise<void>; count(): Promise<number>; first(): Locator; innerText(): Promise<string>; getAttribute(name: string): Promise<string | null> }
|
|
237
|
+
type PwPage = {
|
|
238
|
+
on(event: string, handler: (arg: never) => void): void
|
|
239
|
+
goto(url: string, opts?: { waitUntil?: string }): Promise<unknown>
|
|
240
|
+
waitForLoadState(state: string, opts?: { timeout?: number }): Promise<void>
|
|
241
|
+
waitForSelector(sel: string, opts?: { timeout?: number }): Promise<unknown>
|
|
242
|
+
waitForURL(pred: (u: URL) => boolean, opts?: { timeout?: number }): Promise<void>
|
|
243
|
+
waitForTimeout(ms: number): Promise<void>
|
|
244
|
+
locator(sel: string): Locator
|
|
245
|
+
getByLabel(text: string): Locator
|
|
246
|
+
getByRole(role: string, opts: { name: string | RegExp }): Locator
|
|
247
|
+
screenshot(opts: { path: string; fullPage?: boolean }): Promise<unknown>
|
|
248
|
+
evaluate<T>(fn: () => T): Promise<T>
|
|
249
|
+
}
|
|
250
|
+
type PwBrowser = { newContext(opts: { viewport: { width: number; height: number } }): Promise<{ newPage(): Promise<PwPage> }>; close(): Promise<void> }
|
|
251
|
+
type Playwright = { chromium: { launch(opts: { headless: boolean }): Promise<PwBrowser> } }
|
|
252
|
+
|
|
253
|
+
export const PLAYWRIGHT_HINT = 'FAIL playwright is not installed in this repo — run: bun add -d playwright && npx playwright install chromium'
|
|
254
|
+
|
|
255
|
+
/** resolved from the REPO, never from this package: the browser is the
|
|
256
|
+
* consumer's dependency, and the message names the two commands that fix it */
|
|
257
|
+
export async function loadPlaywright(root: string): Promise<Playwright> {
|
|
258
|
+
let resolved: string
|
|
259
|
+
try {
|
|
260
|
+
resolved = Bun.resolveSync('playwright', root)
|
|
261
|
+
} catch {
|
|
262
|
+
throw new Error(PLAYWRIGHT_HINT)
|
|
263
|
+
}
|
|
264
|
+
return (await import(resolved)) as Playwright
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const isSelector = (spec: string): boolean => /^[#.[]/.test(spec) || /^[a-z][\w-]*(\[|:|$)/.test(spec) || spec.includes(' > ')
|
|
268
|
+
|
|
269
|
+
const control = (page: PwPage, spec: string, role: 'field' | 'submit'): Locator =>
|
|
270
|
+
isSelector(spec) ? page.locator(spec).first() : role === 'field' ? page.getByLabel(spec) : page.getByRole('button', { name: spec })
|
|
271
|
+
|
|
272
|
+
/** wait for the app to SETTLE, not merely to load: a MobX page fires its
|
|
273
|
+
* queries after mount, so `networkidle` samples a shell with a nav and no
|
|
274
|
+
* content. The body text is sampled until it stops growing, and a `waitFor`
|
|
275
|
+
* selector is awaited first, because a spinner holds the text length
|
|
276
|
+
* constant and "stopped growing" alone reports a loading page as settled. */
|
|
277
|
+
async function settle(page: PwPage, waitFor?: string): Promise<void> {
|
|
278
|
+
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
|
279
|
+
if (waitFor != null) await page.waitForSelector(waitFor, { timeout: 40_000 }).catch(() => {})
|
|
280
|
+
let previous = -1
|
|
281
|
+
for (let i = 0; i < 30; i++) {
|
|
282
|
+
const size = (await page.locator('body').innerText().catch(() => '')).trim().length
|
|
283
|
+
if (size === previous && size > 0) return
|
|
284
|
+
previous = size
|
|
285
|
+
await page.waitForTimeout(400)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type SmokeIo = { print: (l: string) => void }
|
|
290
|
+
|
|
291
|
+
export type SmokeRunOptions = { base?: string; routes?: string; credsReadCommand?: string }
|
|
292
|
+
|
|
293
|
+
/** the run. Returns the report; the caller renders it and picks the exit code. */
|
|
294
|
+
export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRunOptions, io: SmokeIo): Promise<SmokeReport> {
|
|
295
|
+
const base = (opts.base ?? config.base).replace(/\/$/, '')
|
|
296
|
+
const routes = selectRoutes(normalizeRoutes(config.routes), opts.routes)
|
|
297
|
+
const paceMs = config.paceMs ?? DEFAULT_PACE_MS
|
|
298
|
+
const shotsRel = config.shots ?? DEFAULT_SHOTS
|
|
299
|
+
const shots = join(root, shotsRel)
|
|
300
|
+
mkdirSync(shots, { recursive: true })
|
|
301
|
+
|
|
302
|
+
// secrets are read BEFORE the browser starts, so a missing one costs no launch
|
|
303
|
+
const login = config.login
|
|
304
|
+
const reader = opts.credsReadCommand ?? ''
|
|
305
|
+
if (login != null && reader === '') throw new Error('FAIL smoke.login needs credsReadCommand in repo.config.ts (defineRepo refuses the pair, so this config bypassed it)')
|
|
306
|
+
const identity = login == null ? null : (login.email ?? readSecret(reader, login.emailSecret!, 'account'))
|
|
307
|
+
const password = login == null ? null : readSecret(reader, login.passwordSecret, 'get')
|
|
308
|
+
|
|
309
|
+
const pw = await loadPlaywright(root)
|
|
310
|
+
const browser = await pw.chromium.launch({ headless: true })
|
|
311
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
|
312
|
+
const page = await ctx.newPage()
|
|
313
|
+
const problems: Problem[] = []
|
|
314
|
+
const quota: Quota = { remaining: Number.NaN, limit: Number.NaN, resetAt: 0 }
|
|
315
|
+
const loginPath = login?.path ?? DEFAULT_LOGIN_PATH
|
|
316
|
+
let current = login != null ? loginPath : routes[0]?.path ?? '/'
|
|
317
|
+
|
|
318
|
+
page.on('console', (m: { type(): string; text(): string }) => {
|
|
319
|
+
if (m.type() !== 'error' && m.type() !== 'warning') return
|
|
320
|
+
const text = m.text()
|
|
321
|
+
if (isNoise(text)) return
|
|
322
|
+
problems.push({ route: current, kind: `console.${m.type()}`, detail: text.slice(0, 300) })
|
|
323
|
+
})
|
|
324
|
+
page.on('pageerror', (e: Error) => problems.push({ route: current, kind: 'pageerror', detail: String(e.message).slice(0, 300) }))
|
|
325
|
+
page.on('requestfailed', (r: { url(): string; failure(): { errorText: string } | null }) => {
|
|
326
|
+
const url = r.url()
|
|
327
|
+
if (isNoise(url)) return
|
|
328
|
+
problems.push({ route: current, kind: 'requestfailed', detail: `${url} ${r.failure()?.errorText ?? ''}`.slice(0, 300) })
|
|
329
|
+
})
|
|
330
|
+
page.on('response', (r: { headers(): Record<string, string>; status(): number; url(): string }) => {
|
|
331
|
+
const h = r.headers()
|
|
332
|
+
// PocketHost's budget, read off every response that carries it
|
|
333
|
+
const remaining = h['x-pockethost-ratelimit-ip-hourly-remaining']
|
|
334
|
+
if (remaining != null) {
|
|
335
|
+
quota.remaining = Number(remaining)
|
|
336
|
+
quota.limit = Number(h['x-pockethost-ratelimit-ip-hourly-limit'] ?? Number.NaN)
|
|
337
|
+
quota.resetAt = Number(h['x-pockethost-ratelimit-ip-hourly-reset'] ?? 0)
|
|
338
|
+
}
|
|
339
|
+
if (r.status() < 400 || isNoise(r.url())) return
|
|
340
|
+
problems.push({ route: current, kind: `http.${r.status()}`, detail: r.url().slice(0, 300) })
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
io.print(`smoke: ${base}${identity != null ? ` as ${identity}` : ''}`)
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const reports: RouteReport[] = []
|
|
347
|
+
if (login != null) {
|
|
348
|
+
await page.goto(`${base}${loginPath}`, { waitUntil: 'domcontentloaded' })
|
|
349
|
+
await settle(page)
|
|
350
|
+
await control(page, login.form.email, 'field').fill(identity!)
|
|
351
|
+
await control(page, login.form.password, 'field').fill(password!)
|
|
352
|
+
await control(page, login.form.submit, 'submit').click()
|
|
353
|
+
const landed = await page
|
|
354
|
+
.waitForURL((u) => !u.pathname.startsWith(loginPath), { timeout: 25_000 })
|
|
355
|
+
.then(() => true)
|
|
356
|
+
.catch(() => false)
|
|
357
|
+
await settle(page)
|
|
358
|
+
if (!landed) {
|
|
359
|
+
const visible = await page.locator('body').innerText().catch(() => '')
|
|
360
|
+
await page.screenshot({ path: join(shots, 'login-failed.png'), fullPage: true })
|
|
361
|
+
throw new Error(`FAIL login as ${identity} never left ${loginPath}. The page said:\n${visible.slice(0, 600)}`)
|
|
362
|
+
}
|
|
363
|
+
io.print('ok: login')
|
|
364
|
+
// a problem during the login is a failure of its own row, or a 500
|
|
365
|
+
// right after the login would exit 0
|
|
366
|
+
if (problems.length > 0) reports.push(judgeRoute({ route: loginPath, chars: 40, matches: 0, problems: problems.length, overflowPx: 0 }))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** visit one url, settle it, judge it, screenshot it */
|
|
370
|
+
const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
|
|
371
|
+
// let the previous page's requests drain out of the concurrency
|
|
372
|
+
// window BEFORE the label moves, or its late failures land on this row
|
|
373
|
+
if (paceMs > 0) await page.waitForTimeout(paceMs)
|
|
374
|
+
current = label
|
|
375
|
+
const before = problems.length
|
|
376
|
+
await page.goto(`${base}${url}`, { waitUntil: 'domcontentloaded' })
|
|
377
|
+
await settle(page, waitFor)
|
|
378
|
+
const chars = (await page.locator('body').innerText().catch(() => '')).trim().length
|
|
379
|
+
const matches = waitFor == null ? 0 : await page.locator(waitFor).count().catch(() => 0)
|
|
380
|
+
const overflowPx = await page.evaluate(() => {
|
|
381
|
+
const de = document.documentElement
|
|
382
|
+
return Math.max(de.scrollWidth - de.clientWidth, document.body.scrollWidth - document.body.clientWidth)
|
|
383
|
+
}).catch(() => 0)
|
|
384
|
+
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
385
|
+
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const route of routes) {
|
|
389
|
+
reports.push(await visit(route.path, route.path, route.waitFor))
|
|
390
|
+
if (route.open == null) continue
|
|
391
|
+
// a detail page with a real id: the first link the selector matches
|
|
392
|
+
const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
|
|
393
|
+
if (href == null || !href.startsWith('/')) {
|
|
394
|
+
reports.push({ route: `${route.path} → ${route.open}`, ok: false, chars: 0, matches: 0, problems: 0, note: 'NO LINK TO OPEN' })
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
reports.push(await visit(`${route.path} → ${href}`, href))
|
|
398
|
+
}
|
|
399
|
+
return { base, identity, routes: reports, problems, quota: Number.isNaN(quota.remaining) ? null : quota, shots: shotsRel }
|
|
400
|
+
} finally {
|
|
401
|
+
await browser.close()
|
|
402
|
+
}
|
|
403
|
+
}
|