rvlib-shipkit 0.35.7 → 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.
@@ -0,0 +1,50 @@
1
+ // SK014 — the STE prose standard, in the gate (identity/writing-voice.md).
2
+ //
3
+ // Opt-in by declaration: a repo with no `prose.include` is SKIPPED, not
4
+ // failed. STE is house doctrine for prose an agent writes, and a rule that
5
+ // turned every existing repo red the day it shipped would be turned off
6
+ // rather than obeyed.
7
+ //
8
+ // The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
9
+ // mechanical, and a machine reporting them is never guessing. `soft` hits
10
+ // (sentence length, passive voice) need a human read, so they WARN, and only
11
+ // a declared `baseline` makes them blocking, as a ratchet that can only go
12
+ // down.
13
+ //
14
+ // It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
15
+ // default, so a rule with a fixer rewrites tracked files unattended, and
16
+ // prose is not repo shape: a wrong package.json line is a config, a wrong
17
+ // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
+ // --fix` that a human ran and can read the diff of.
19
+ import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
20
+ import type { Rule } from './rule.ts'
21
+
22
+ const OWNER = 'identity/writing-voice.md'
23
+
24
+ export const proseSte: Rule = {
25
+ id: 'SK014',
26
+ blocking: true,
27
+ title: 'STE prose',
28
+ owner: OWNER,
29
+ inputs: ['.'],
30
+ lint: (t, env) => {
31
+ const cfg = env.config.prose
32
+ if (cfg == null || (cfg.include ?? []).length === 0) {
33
+ t.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
34
+ return
35
+ }
36
+ const r = runProseCheck({ root: env.root, config: cfg })
37
+ const blocking = r.byTier.fix + r.byTier.hard
38
+ for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) t.note(l)
39
+ if (blocking > 0)
40
+ t.fail(
41
+ `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
42
+ r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
43
+ )
44
+ if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
45
+ t.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
46
+ else if (r.byTier.soft > 0)
47
+ t.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
48
+ if (!t.failed && blocking === 0) t.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
49
+ }
50
+ }
@@ -16,6 +16,7 @@ import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
16
  import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
17
  import { configBarrelImport } from './SK012-config-barrel-import.ts'
18
18
  import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
19
+ import { proseSte } from './SK014-prose-ste.ts'
19
20
 
20
21
  export const RULES: readonly Rule[] = [
21
22
  packageJsonShape,
@@ -31,6 +32,7 @@ export const RULES: readonly Rule[] = [
31
32
  siblingUnitRanges,
32
33
  configBarrelImport,
33
34
  oneWayInPolicy,
35
+ proseSte,
34
36
  ]
35
37
 
36
38
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
@@ -31,13 +31,16 @@ export const GENERATED_IGNORE_LINES = [
31
31
  'STATUS.md',
32
32
  'STATUS.md.lock',
33
33
  '.STATUS.md.tmp-*',
34
+ 'STATUS.full.md',
35
+ 'STATUS.full.md.lock',
36
+ '.STATUS.full.md.tmp-*',
34
37
  ]
35
38
 
36
39
  /** the LITERAL subset an autofix may remove from the index. Never a glob and
37
40
  * never derived from a pattern: `git rm --cached` driven by a wildcard is how
38
41
  * a fixer eats source it was never pointed at. A future entry is added HERE,
39
42
  * by hand, spelled out. */
40
- export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock'] as const
43
+ export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock', 'STATUS.full.md', 'STATUS.full.md.lock'] as const
41
44
 
42
45
  export type UntrackOutcome =
43
46
  | { did: 'nothing' }
@@ -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
+ }