@erclx/canon 4.96.0 → 4.97.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.
Files changed (30) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/codebase-layout/REQUIREMENT.md +46 -0
  3. package/claude/skills/codebase-layout/SKILL.md +57 -0
  4. package/claude/skills/codebase-layout/references/adopted.md +34 -0
  5. package/claude/skills/codebase-layout/references/frontend.md +43 -0
  6. package/claude/skills/codebase-layout/references/tests.md +28 -0
  7. package/claude/skills/git-pr/REQUIREMENT.md +3 -0
  8. package/claude/skills/git-pr/SKILL.md +25 -0
  9. package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
  10. package/claude/skills/plan-feature/SKILL.md +2 -1
  11. package/claude/skills/test-craft/REQUIREMENT.md +9 -0
  12. package/claude/skills/test-craft/references/e2e.md +1 -1
  13. package/docs/agents/commands.md +2 -1
  14. package/docs/agents/index.md +1 -0
  15. package/docs/agents/pr-evidence.md +9 -1
  16. package/docs/agents/pr-preview.md +103 -0
  17. package/docs/workflow/ai-workflow.md +1 -0
  18. package/governance/rules/core/000-constitution.md +1 -1
  19. package/governance/rules/lib/300-testing-ts.md +1 -0
  20. package/package.json +1 -1
  21. package/src/claude/cases/misc.ts +5 -0
  22. package/src/commands/pr.ts +300 -13
  23. package/src/pr/evidence.ts +36 -1
  24. package/src/pr/preview.ts +178 -0
  25. package/tooling/astro/configs/playwright.config.ts +3 -1
  26. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +64 -2
  27. package/tooling/cloudflare/reference.md +6 -3
  28. package/tooling/nextjs/configs/playwright.config.ts +3 -1
  29. package/tooling/vite-react/configs/playwright.config.ts +3 -1
  30. package/tooling/web/configs/e2e/home.spec.ts +8 -0
@@ -1,5 +1,6 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
3
+ import { basename, join, resolve } from 'node:path'
3
4
  import { $ } from 'bun'
4
5
  import type { Command } from 'commander'
5
6
  import { execa } from 'execa'
@@ -22,11 +23,20 @@ import {
22
23
  import { type CheckRunListing, collapseChecks } from '@/pr/checks'
23
24
  import {
24
25
  findEvidenceCommentId,
26
+ findEvidencePreview,
25
27
  groupEvidence,
26
28
  renderEvidenceBody,
27
29
  } from '@/pr/evidence'
28
30
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
29
31
  import { KEY_CHANGES } from '@/pr/paths'
32
+ import {
33
+ findDeployWorkflow,
34
+ mintPreview,
35
+ type PreviewRefusal,
36
+ type PreviewRunner,
37
+ type RunRow,
38
+ type WorkflowFile,
39
+ } from '@/pr/preview'
30
40
  import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
31
41
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
32
42
 
@@ -58,6 +68,37 @@ interface ReadOptions {
58
68
  readonly json?: boolean
59
69
  }
60
70
 
71
+ interface EvidenceOptions extends ReadOptions {
72
+ readonly preview?: string
73
+ }
74
+
75
+ interface PreviewOptions extends ReadOptions {
76
+ readonly timeout?: string
77
+ }
78
+
79
+ /** How long `canon pr preview` waits on the deploy run by default, in minutes. */
80
+ const PREVIEW_TIMEOUT_MINUTES = 15
81
+
82
+ /** How often the deploy run is re-read while waiting on it. */
83
+ const PREVIEW_POLL_MS = 15_000
84
+
85
+ const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
86
+ 'bad-timeout': '--timeout takes a positive number of minutes.',
87
+ 'no-deploy':
88
+ 'No workflow under .github/workflows/ runs pages deploy and carries a workflow_dispatch trigger, so there is nothing to dispatch.',
89
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
90
+ unfenced:
91
+ 'The deploy workflow passes no --branch=${{ github.ref_name }}, so a dispatch from this branch would publish it to production. Add the flag before asking for a preview.',
92
+ 'no-alias':
93
+ 'The deploy never printed a canon-preview-alias line, so no address could be read. Add the alias step from the cloudflare stack reference to the workflow.',
94
+ 'gh-failed':
95
+ 'gh could not list or dispatch the deploy workflow for this branch.',
96
+ 'run-failed':
97
+ 'The deploy run finished without succeeding, so no preview was published. Read the run log.',
98
+ timeout:
99
+ 'The deploy run did not finish inside the bound. The preview may still land. Re-run with a longer --timeout or read the run.',
100
+ }
101
+
61
102
  /** Why a head-sensitive read produced no answer about a commit. */
62
103
  type PullRefusal =
63
104
  | 'gh-missing'
@@ -324,6 +365,10 @@ export function register(program: Command): void {
324
365
  .helpOption('-h, --help', 'Show this help message')
325
366
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
326
367
  .option('--json', 'Add a machine-readable record on stdout')
368
+ .option(
369
+ '--preview <url>',
370
+ 'Open the body with this preview address, from canon pr preview',
371
+ )
327
372
  .addHelpText(
328
373
  'after',
329
374
  [
@@ -342,6 +387,12 @@ export function register(program: Command): void {
342
387
  ' no-evidence nothing in the diff carries an evidence/ segment, which',
343
388
  ' is an ordinary, silent no-op rather than a refusal',
344
389
  '',
390
+ "--preview puts the address on the body's first line. With no evidence",
391
+ 'in the diff, the body is that line and the marker alone, reported as',
392
+ 'ok, so the preview still lands in one comment a later call can edit.',
393
+ 'Without --preview, an address the marked comment already opens with',
394
+ 'is carried into the new body, so a re-render after a push keeps it.',
395
+ '',
345
396
  'Exit codes:',
346
397
  ' 0 read, whether it produced a body or reported no-evidence',
347
398
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -349,12 +400,63 @@ export function register(program: Command): void {
349
400
  'Examples:',
350
401
  ' canon pr evidence --json',
351
402
  ' canon pr evidence 1341 --json',
403
+ ' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
352
404
  '',
353
405
  ].join('\n'),
354
406
  )
355
- .action(async (number: string | undefined, opts: ReadOptions) => {
407
+ .action(async (number: string | undefined, opts: EvidenceOptions) => {
356
408
  process.exitCode = await runEvidence(number, opts)
357
409
  })
410
+
411
+ pr.command('preview')
412
+ .description(
413
+ "Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
414
+ )
415
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
416
+ .helpOption('-h, --help', 'Show this help message')
417
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
418
+ .option('--json', 'Add a machine-readable record on stdout')
419
+ .option(
420
+ '--timeout <minutes>',
421
+ 'How long to wait on the deploy run',
422
+ String(PREVIEW_TIMEOUT_MINUTES),
423
+ )
424
+ .addHelpText(
425
+ 'after',
426
+ [
427
+ '',
428
+ 'Finds the workflow under .github/workflows/ that runs pages deploy with',
429
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
430
+ '--branch=${{ github.ref_name }} and a workflow_dispatch trigger,',
431
+ "dispatches it on the pull request's head branch, waits on that run,",
432
+ 'and reads the address from the canon-preview-alias line the workflow',
433
+ 'prints. The address is read rather than computed from the branch name,',
434
+ 'since Cloudflare flattens and may truncate a branch into its alias.',
435
+ '',
436
+ 'A deploy without the --branch flag is refused before anything runs,',
437
+ 'because Pages publishes an unfenced deploy to production.',
438
+ '',
439
+ 'Read `reason` on the JSON record:',
440
+ ' ok the preview was published, with its address in `url`',
441
+ ' no-deploy no dispatchable workflow runs pages deploy',
442
+ ' unfenced the deploy passes no --branch, so nothing was dispatched',
443
+ ' no-alias the workflow prints no canon-preview-alias line',
444
+ ' run-failed the deploy run finished without succeeding',
445
+ ' timeout the run did not finish inside --timeout minutes',
446
+ '',
447
+ 'Exit codes:',
448
+ ' 0 a preview was published',
449
+ ' 1 refused, with the reason on stderr or in the JSON record',
450
+ '',
451
+ 'Examples:',
452
+ ' canon pr preview --json',
453
+ ' canon pr preview 1341 --json --timeout 20',
454
+ '',
455
+ ].join('\n'),
456
+ )
457
+ .action(async (number: string | undefined, opts: PreviewOptions) => {
458
+ process.exitCode = await runPreview(number, opts)
459
+ })
358
460
  }
359
461
 
360
462
  interface PullRequestRead {
@@ -1105,7 +1207,7 @@ async function listTreePaths(
1105
1207
 
1106
1208
  async function runEvidence(
1107
1209
  number: string | undefined,
1108
- opts: ReadOptions,
1210
+ opts: EvidenceOptions,
1109
1211
  ): Promise<number> {
1110
1212
  const root = resolve(opts.root ?? process.cwd())
1111
1213
  const emitJson = opts.json ?? false
@@ -1157,7 +1259,7 @@ async function runEvidence(
1157
1259
  baseTree.has(path),
1158
1260
  )
1159
1261
 
1160
- if (grouped.kind === 'refused') {
1262
+ if (grouped.kind === 'refused' && opts.preview === undefined) {
1161
1263
  logStep('Skipped')
1162
1264
  logInfo(
1163
1265
  'No changed path carries an evidence/ segment, so there is nothing to post.',
@@ -1190,9 +1292,8 @@ async function runEvidence(
1190
1292
  )
1191
1293
  }
1192
1294
 
1193
- const body = renderEvidenceBody(grouped.states, repo, base, identity.head)
1194
-
1195
1295
  let commentId: number | undefined
1296
+ let carriedPreview: string | undefined
1196
1297
  if (identity.number !== undefined) {
1197
1298
  const commentsRow = await gh(root, [
1198
1299
  'pr',
@@ -1201,24 +1302,44 @@ async function runEvidence(
1201
1302
  '--json',
1202
1303
  'comments',
1203
1304
  ])
1305
+ // An unread thread refuses rather than rendering, since a body built
1306
+ // without it knows neither the comment to edit nor the preview address
1307
+ // to carry, and posting it would duplicate the comment and drop the link.
1308
+ let comments: readonly { url?: string; body: string }[] | undefined
1204
1309
  if (commentsRow !== null) {
1205
1310
  try {
1206
- const parsed = JSON.parse(commentsRow) as {
1207
- comments?: readonly { url?: string; body: string }[]
1208
- }
1209
- commentId = findEvidenceCommentId(parsed.comments ?? [])
1311
+ comments = (
1312
+ JSON.parse(commentsRow) as {
1313
+ comments?: readonly { url?: string; body: string }[]
1314
+ }
1315
+ ).comments
1210
1316
  } catch {
1211
- commentId = undefined
1317
+ comments = undefined
1212
1318
  }
1213
1319
  }
1320
+ if (comments === undefined) {
1321
+ return refuseWith(
1322
+ 'gh-failed',
1323
+ EVIDENCE_REFUSALS['gh-failed'],
1324
+ emitJson,
1325
+ root,
1326
+ )
1327
+ }
1328
+ commentId = findEvidenceCommentId(comments)
1329
+ carriedPreview = findEvidencePreview(comments)
1214
1330
  }
1215
1331
 
1216
- const caseCount = grouped.states.reduce((n, s) => n + s.items.length, 0)
1332
+ const preview = opts.preview ?? carriedPreview
1333
+ const states = grouped.kind === 'read' ? grouped.states : []
1334
+ const body = renderEvidenceBody(states, repo, base, identity.head, preview)
1335
+
1336
+ const caseCount = states.reduce((n, s) => n + s.items.length, 0)
1217
1337
 
1218
1338
  logStep('Scope')
1219
1339
  logInfo(
1220
- `${plural(caseCount, 'case')} across ${plural(grouped.states.length, 'state')}`,
1340
+ `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
1221
1341
  )
1342
+ if (preview !== undefined) logInfo(`preview ${preview}`)
1222
1343
 
1223
1344
  outro()
1224
1345
 
@@ -1239,6 +1360,172 @@ async function runEvidence(
1239
1360
  return 0
1240
1361
  }
1241
1362
 
1363
+ async function readWorkflows(root: string): Promise<WorkflowFile[]> {
1364
+ const dir = join(root, '.github', 'workflows')
1365
+ if (!existsSync(dir)) return []
1366
+ const paths = [
1367
+ ...new Bun.Glob('*.{yml,yaml}').scanSync({ cwd: dir, onlyFiles: true }),
1368
+ ]
1369
+ return Promise.all(
1370
+ paths.map(async (name) => ({
1371
+ path: `.github/workflows/${name}`,
1372
+ text: await readFile(join(dir, name), 'utf8'),
1373
+ })),
1374
+ )
1375
+ }
1376
+
1377
+ function parseRun(stdout: string | null): RunRow | undefined {
1378
+ if (stdout === null) return undefined
1379
+ try {
1380
+ return JSON.parse(stdout) as RunRow
1381
+ } catch {
1382
+ return undefined
1383
+ }
1384
+ }
1385
+
1386
+ /**
1387
+ * The `gh` half of a preview. `--workflow` and `gh workflow run` both take the
1388
+ * workflow's file name rather than its repository path.
1389
+ */
1390
+ function ghPreviewRunner(root: string): PreviewRunner {
1391
+ return {
1392
+ async dispatch(workflow, ref) {
1393
+ const out = await gh(root, [
1394
+ 'workflow',
1395
+ 'run',
1396
+ basename(workflow),
1397
+ '--ref',
1398
+ ref,
1399
+ ])
1400
+ return out !== null
1401
+ },
1402
+ async listRuns(workflow, ref) {
1403
+ const out = await gh(root, [
1404
+ 'run',
1405
+ 'list',
1406
+ '--workflow',
1407
+ basename(workflow),
1408
+ '--branch',
1409
+ ref,
1410
+ '--event',
1411
+ 'workflow_dispatch',
1412
+ '--limit',
1413
+ '20',
1414
+ '--json',
1415
+ 'databaseId,status,conclusion',
1416
+ ])
1417
+ if (out === null) return undefined
1418
+ try {
1419
+ return JSON.parse(out) as RunRow[]
1420
+ } catch {
1421
+ return undefined
1422
+ }
1423
+ },
1424
+ async viewRun(id) {
1425
+ return parseRun(
1426
+ await gh(root, [
1427
+ 'run',
1428
+ 'view',
1429
+ String(id),
1430
+ '--json',
1431
+ 'databaseId,status,conclusion',
1432
+ ]),
1433
+ )
1434
+ },
1435
+ async readLog(id) {
1436
+ return (await gh(root, ['run', 'view', String(id), '--log'])) ?? undefined
1437
+ },
1438
+ now: () => Date.now(),
1439
+ sleep: (ms) => Bun.sleep(ms),
1440
+ }
1441
+ }
1442
+
1443
+ async function runPreview(
1444
+ number: string | undefined,
1445
+ opts: PreviewOptions,
1446
+ ): Promise<number> {
1447
+ const root = resolve(opts.root ?? process.cwd())
1448
+ const emitJson = opts.json ?? false
1449
+
1450
+ intro('canon pr preview')
1451
+
1452
+ const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
1453
+ if (!Number.isFinite(minutes) || minutes <= 0) {
1454
+ return refuseWith(
1455
+ 'bad-timeout',
1456
+ PREVIEW_REFUSALS['bad-timeout'],
1457
+ emitJson,
1458
+ root,
1459
+ )
1460
+ }
1461
+
1462
+ // Read ahead of the pull request, so a project with no fenced deploy is
1463
+ // refused without a network call and never reaches a dispatch.
1464
+ const pick = findDeployWorkflow(await readWorkflows(root))
1465
+ if (pick.kind === 'refused') {
1466
+ return refuseWith(
1467
+ pick.reason,
1468
+ PREVIEW_REFUSALS[pick.reason],
1469
+ emitJson,
1470
+ root,
1471
+ )
1472
+ }
1473
+
1474
+ const read = await readIdentity(root, number)
1475
+ if (read.kind === 'refused') {
1476
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
1477
+ }
1478
+ const { identity } = read
1479
+
1480
+ logStep('Deploy')
1481
+ logInfo(`${pick.path} on ${identity.branch}`)
1482
+
1483
+ const result = await mintPreview(ghPreviewRunner(root), {
1484
+ workflow: pick.path,
1485
+ branch: identity.branch,
1486
+ timeoutMs: minutes * 60_000,
1487
+ pollMs: PREVIEW_POLL_MS,
1488
+ })
1489
+
1490
+ if (result.kind === 'refused') {
1491
+ logStep('Refused')
1492
+ logWarn(PREVIEW_REFUSALS[result.reason])
1493
+ outro()
1494
+ if (emitJson) {
1495
+ process.stdout.write(
1496
+ `${JSON.stringify({
1497
+ root,
1498
+ ...(identity.number !== undefined && { number: identity.number }),
1499
+ reason: result.reason,
1500
+ message: PREVIEW_REFUSALS[result.reason],
1501
+ workflow: pick.path,
1502
+ ...(result.runId !== undefined && { runId: result.runId }),
1503
+ })}\n`,
1504
+ )
1505
+ }
1506
+ return 1
1507
+ }
1508
+
1509
+ logStep('Preview')
1510
+ logInfo(result.url)
1511
+ outro()
1512
+
1513
+ if (emitJson) {
1514
+ process.stdout.write(
1515
+ `${JSON.stringify({
1516
+ root,
1517
+ ...(identity.number !== undefined && { number: identity.number }),
1518
+ reason: 'ok',
1519
+ url: result.url,
1520
+ workflow: pick.path,
1521
+ runId: result.runId,
1522
+ })}\n`,
1523
+ )
1524
+ }
1525
+
1526
+ return 0
1527
+ }
1528
+
1242
1529
  /**
1243
1530
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
1244
1531
  * so an operator reading the terminal sees the reason rather than a command
@@ -4,6 +4,9 @@ const EVIDENCE_SEGMENT = 'evidence'
4
4
  /** Prefix of the trailing marker this module writes and reads back. */
5
5
  const MARKER_PREFIX = '<!-- pr-evidence:'
6
6
 
7
+ /** How the first line of a body names the branch's preview deployment. */
8
+ const PREVIEW_PREFIX = '**Preview:**'
9
+
7
10
  /** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
8
11
  const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
9
12
 
@@ -109,13 +112,24 @@ export function evidenceMarker(head: string): string {
109
112
  * a Base/Head row per case, and the trailing marker naming the head this body
110
113
  * describes. Every image URL is pinned to a commit sha rather than a branch,
111
114
  * so the comment keeps showing what it claimed even after the branch moves.
115
+ *
116
+ * A preview address opens the body, and with no states it is the whole body
117
+ * apart from the marker, so a pull request whose screenshots did not change
118
+ * still gets one comment a later call can find and edit in place.
112
119
  */
113
120
  export function renderEvidenceBody(
114
121
  states: readonly EvidenceState[],
115
122
  repo: string,
116
123
  base: string,
117
124
  head: string,
125
+ preview?: string,
118
126
  ): string {
127
+ const opening =
128
+ preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
129
+ if (states.length === 0) {
130
+ return [...opening, evidenceMarker(head)].join('\n')
131
+ }
132
+
119
133
  const sections = states.map((entry) => {
120
134
  const rows = entry.items.map((item) => {
121
135
  const before = item.added
@@ -137,7 +151,14 @@ export function renderEvidenceBody(
137
151
  ].join('\n')
138
152
  })
139
153
 
140
- return ['## Evidence', '', ...sections, '', evidenceMarker(head)].join('\n')
154
+ return [
155
+ ...opening,
156
+ '## Evidence',
157
+ '',
158
+ ...sections,
159
+ '',
160
+ evidenceMarker(head),
161
+ ].join('\n')
141
162
  }
142
163
 
143
164
  export interface EvidenceComment {
@@ -169,3 +190,17 @@ export function findEvidenceCommentId(
169
190
  }
170
191
  return undefined
171
192
  }
193
+
194
+ /**
195
+ * The preview address the marked comment already opens with. A re-render
196
+ * after a later push passes no address of its own, and carrying this one
197
+ * forward keeps that edit from deleting the link a reviewer is using.
198
+ */
199
+ export function findEvidencePreview(
200
+ comments: readonly EvidenceComment[],
201
+ ): string | undefined {
202
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
203
+ const first = marked?.body.split('\n')[0]?.trim() ?? ''
204
+ if (!first.startsWith(PREVIEW_PREFIX)) return undefined
205
+ return first.slice(PREVIEW_PREFIX.length).trim() || undefined
206
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The line a deploy workflow prints once Cloudflare returns the branch alias.
3
+ * The workflow and this reader agree on it and on nothing else, so the text is
4
+ * a contract with `tooling/cloudflare/configs/.github/workflows/deploy.yml`.
5
+ */
6
+ const ALIAS_MARKER = 'canon-preview-alias:'
7
+
8
+ /**
9
+ * Anchored on the address itself, since GitHub echoes a step's script above
10
+ * its output and that echo carries the marker with `$ALIAS_URL` unexpanded.
11
+ */
12
+ const ALIAS_LINE = /canon-preview-alias:\s*(https:\/\/\S+)/
13
+
14
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
15
+ const BRANCH_FENCE = '--branch=${{ github.ref_name }}'
16
+
17
+ export type PreviewRefusal =
18
+ | 'no-deploy'
19
+ | 'unfenced'
20
+ | 'no-alias'
21
+ | 'gh-failed'
22
+ | 'run-failed'
23
+ | 'timeout'
24
+
25
+ export interface WorkflowFile {
26
+ readonly path: string
27
+ readonly text: string
28
+ }
29
+
30
+ export type WorkflowPick =
31
+ | { readonly kind: 'found'; readonly path: string }
32
+ | {
33
+ readonly kind: 'refused'
34
+ readonly reason: 'no-deploy' | 'unfenced' | 'no-alias'
35
+ }
36
+
37
+ interface WorkflowShape {
38
+ readonly path: string
39
+ readonly deployLines: readonly string[]
40
+ readonly isDispatchable: boolean
41
+ readonly hasMarker: boolean
42
+ }
43
+
44
+ /**
45
+ * Reads a workflow's lines with YAML comment lines dropped, since a comment
46
+ * naming the fence would otherwise pass for a fenced command.
47
+ */
48
+ function readShape(file: WorkflowFile): WorkflowShape {
49
+ const lines = file.text
50
+ .split('\n')
51
+ .filter((line) => !line.trimStart().startsWith('#'))
52
+ return {
53
+ path: file.path,
54
+ deployLines: lines.filter((line) => line.includes('pages deploy')),
55
+ isDispatchable: lines.some((line) => /^\s*workflow_dispatch:/.test(line)),
56
+ hasMarker: lines.some((line) => line.includes(ALIAS_MARKER)),
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Picks the workflow a preview dispatch runs: one that deploys to Pages and
62
+ * can be dispatched. A workflow with any `pages deploy` line passing no
63
+ * `--branch` is refused rather than dispatched, because Pages then publishes
64
+ * whatever ref ran to production.
65
+ */
66
+ export function findDeployWorkflow(
67
+ files: readonly WorkflowFile[],
68
+ ): WorkflowPick {
69
+ const dispatchable = [...files]
70
+ .sort((a, b) => a.path.localeCompare(b.path))
71
+ .map(readShape)
72
+ .filter((shape) => shape.deployLines.length > 0 && shape.isDispatchable)
73
+ if (dispatchable.length === 0) return { kind: 'refused', reason: 'no-deploy' }
74
+
75
+ const fenced = dispatchable.filter((shape) =>
76
+ shape.deployLines.every((line) => line.includes(BRANCH_FENCE)),
77
+ )
78
+ if (fenced.length === 0) return { kind: 'refused', reason: 'unfenced' }
79
+
80
+ const marked = fenced.find((shape) => shape.hasMarker)
81
+ if (marked === undefined) return { kind: 'refused', reason: 'no-alias' }
82
+
83
+ return { kind: 'found', path: marked.path }
84
+ }
85
+
86
+ export function readPreviewAlias(log: string): string | undefined {
87
+ for (const line of log.split('\n')) {
88
+ const match = ALIAS_LINE.exec(line)
89
+ if (match?.[1] !== undefined) return match[1]
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ export interface RunRow {
95
+ readonly databaseId: number
96
+ readonly status: string
97
+ readonly conclusion: string
98
+ }
99
+
100
+ /** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
101
+ export interface PreviewRunner {
102
+ dispatch(workflow: string, ref: string): Promise<boolean>
103
+ listRuns(
104
+ workflow: string,
105
+ ref: string,
106
+ ): Promise<readonly RunRow[] | undefined>
107
+ viewRun(id: number): Promise<RunRow | undefined>
108
+ readLog(id: number): Promise<string | undefined>
109
+ now(): number
110
+ sleep(ms: number): Promise<void>
111
+ }
112
+
113
+ export interface PreviewSettings {
114
+ readonly workflow: string
115
+ readonly branch: string
116
+ readonly timeoutMs: number
117
+ readonly pollMs: number
118
+ }
119
+
120
+ export type PreviewResult =
121
+ | { readonly kind: 'minted'; readonly runId: number; readonly url: string }
122
+ | {
123
+ readonly kind: 'refused'
124
+ readonly reason: PreviewRefusal
125
+ readonly runId?: number
126
+ }
127
+
128
+ /**
129
+ * Dispatches the deploy on the branch, waits for that run within the bound,
130
+ * and reads the alias out of its log.
131
+ *
132
+ * `gh workflow run` reports no run id, so the run is found as the one id
133
+ * absent from a listing taken before the dispatch. Comparing ids rather than
134
+ * creation times keeps the match independent of this machine's clock.
135
+ */
136
+ export async function mintPreview(
137
+ runner: PreviewRunner,
138
+ settings: PreviewSettings,
139
+ ): Promise<PreviewResult> {
140
+ const { workflow, branch, timeoutMs, pollMs } = settings
141
+ const deadline = runner.now() + timeoutMs
142
+
143
+ const before = await runner.listRuns(workflow, branch)
144
+ if (before === undefined) return { kind: 'refused', reason: 'gh-failed' }
145
+ const known = new Set(before.map((row) => row.databaseId))
146
+
147
+ if (!(await runner.dispatch(workflow, branch))) {
148
+ return { kind: 'refused', reason: 'gh-failed' }
149
+ }
150
+
151
+ let runId: number | undefined
152
+ while (runId === undefined) {
153
+ if (runner.now() >= deadline) return { kind: 'refused', reason: 'timeout' }
154
+ await runner.sleep(pollMs)
155
+ const rows = await runner.listRuns(workflow, branch)
156
+ runId = rows?.find((row) => !known.has(row.databaseId))?.databaseId
157
+ }
158
+
159
+ for (;;) {
160
+ const row = await runner.viewRun(runId)
161
+ if (row?.status === 'completed') {
162
+ if (row.conclusion !== 'success') {
163
+ return { kind: 'refused', reason: 'run-failed', runId }
164
+ }
165
+ break
166
+ }
167
+ if (runner.now() >= deadline) {
168
+ return { kind: 'refused', reason: 'timeout', runId }
169
+ }
170
+ await runner.sleep(pollMs)
171
+ }
172
+
173
+ const log = await runner.readLog(runId)
174
+ const url = log === undefined ? undefined : readPreviewAlias(log)
175
+ if (url === undefined) return { kind: 'refused', reason: 'no-alias', runId }
176
+
177
+ return { kind: 'minted', runId, url }
178
+ }
@@ -14,7 +14,9 @@ export default defineConfig({
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
16
  // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
- reducedMotion: 'reduce',
17
+ // It is a context option rather than a top-level one, so written beside
18
+ // `baseURL` it type-checks nowhere and reaches no page.
19
+ contextOptions: { reducedMotion: 'reduce' },
18
20
  },
19
21
  projects: [
20
22
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },