@erclx/canon 5.64.0 → 5.65.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.
@@ -103,6 +103,11 @@ function escape(value: string): string {
103
103
  * never match. The guard then stops firing in a project the move has not
104
104
  * reached, which is silent: the index goes stale while every save succeeds.
105
105
  *
106
+ * A replay snapshot is a frozen copy of a consumer, its hooks included, and
107
+ * the replay tests the upgrade from exactly that state. Sweeping one rewrites
108
+ * the consumer's own old-root citations, so the snapshot stops matching the
109
+ * release it was cut at.
110
+ *
106
111
  * A file outside this list can still couple to one inside it, naming an
107
112
  * excluded path as plain text while it also carries a live citation of its
108
113
  * own, which `referencesExcluded` reports separately below. Widening this list
@@ -115,6 +120,7 @@ const EXCLUDED_PREFIXES: readonly string[] = [
115
120
  'scripts/eval/result-',
116
121
  '.claude/hooks/',
117
122
  'tooling/claude/seeds/.claude/hooks/',
123
+ 'sandbox/fixtures/replay/',
118
124
  ]
119
125
 
120
126
  const EXCLUDED_PATHS: readonly string[] = [
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { StepResult } from '@/sandbox/replay/run'
4
+
5
+ /** Sits in a snapshot's folder beside its `.fixture` files and never reaches the replay tree. */
6
+ export const EXPECTED_FILE = 'expected.json'
7
+
8
+ export interface StepExpectation {
9
+ readonly exit: number | null
10
+ readonly findings: number
11
+ }
12
+
13
+ /**
14
+ * A snapshot's recorded answer, keyed by step name. It pins the exit and the
15
+ * finding count rather than the finding lines, so rewording a warning moves
16
+ * nothing while a seed sync newly calls missing still drifts.
17
+ */
18
+ export interface ReplayExpectation {
19
+ readonly steps: Readonly<Record<string, StepExpectation>>
20
+ }
21
+
22
+ const RECORD_HINT =
23
+ 'run bun src/cli.ts sandbox replay --record if the change meant it'
24
+
25
+ export function expectationOf(steps: readonly StepResult[]): ReplayExpectation {
26
+ return {
27
+ steps: Object.fromEntries(
28
+ steps.map((s) => [s.step, { exit: s.exit, findings: s.findings.length }]),
29
+ ),
30
+ }
31
+ }
32
+
33
+ function isStepExpectation(value: unknown): value is StepExpectation {
34
+ if (typeof value !== 'object' || value === null) return false
35
+ const { exit, findings } = value as Record<string, unknown>
36
+
37
+ return (
38
+ (exit === null || typeof exit === 'number') && typeof findings === 'number'
39
+ )
40
+ }
41
+
42
+ /**
43
+ * The expectation recorded in `dir`, or undefined when none is. Throws naming
44
+ * the file and the step when the file holds anything but the recorded shape.
45
+ */
46
+ export function readExpectation(dir: string): ReplayExpectation | undefined {
47
+ const path = join(dir, EXPECTED_FILE)
48
+ if (!existsSync(path)) return undefined
49
+
50
+ let parsed: unknown
51
+ try {
52
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
53
+ } catch {
54
+ throw new Error(`${path} does not parse as JSON`)
55
+ }
56
+ const steps =
57
+ typeof parsed === 'object' && parsed !== null
58
+ ? (parsed as Record<string, unknown>).steps
59
+ : undefined
60
+ if (typeof steps !== 'object' || steps === null)
61
+ throw new Error(`${path} carries no steps object`)
62
+ for (const [name, value] of Object.entries(steps))
63
+ if (!isStepExpectation(value))
64
+ throw new Error(
65
+ `${path}: step ${name} needs an exit and a findings count`,
66
+ )
67
+
68
+ return { steps: steps as ReplayExpectation['steps'] }
69
+ }
70
+
71
+ /** Written the way oxfmt leaves JSON, so a recorded file never fails the format stage. */
72
+ export function writeExpectation(
73
+ dir: string,
74
+ expectation: ReplayExpectation,
75
+ ): void {
76
+ writeFileSync(
77
+ join(dir, EXPECTED_FILE),
78
+ `${JSON.stringify(expectation, null, 2)}\n`,
79
+ )
80
+ }
81
+
82
+ function driftOf(
83
+ run: StepResult,
84
+ recorded: StepExpectation | undefined,
85
+ ): string | undefined {
86
+ if (recorded === undefined) return `no recorded answer, ${RECORD_HINT}`
87
+ if (run.exit !== recorded.exit)
88
+ return `exit ${run.exit}, recorded ${recorded.exit}`
89
+ if (run.findings.length !== recorded.findings)
90
+ return `${run.findings.length} findings, recorded ${recorded.findings}`
91
+
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * Judges a snapshot's steps against its recorded answer. A step that already
97
+ * failed keeps its verdict, since a crash names more than a changed exit. A
98
+ * step either side lacks drifts, and with no expectation at all every step
99
+ * does, since a missing input read as a pass is what the replay exists to stop.
100
+ */
101
+ export function judgeDrift(
102
+ steps: readonly StepResult[],
103
+ expectation: ReplayExpectation | undefined,
104
+ ): StepResult[] {
105
+ if (expectation === undefined)
106
+ return steps.map((s) =>
107
+ s.verdict === 'ok'
108
+ ? {
109
+ ...s,
110
+ verdict: 'drifted',
111
+ detail: `no ${EXPECTED_FILE} for this snapshot, run bun src/cli.ts sandbox replay --record`,
112
+ }
113
+ : s,
114
+ )
115
+
116
+ const judged = steps.map((s) => {
117
+ if (s.verdict !== 'ok') return s
118
+ const detail = driftOf(s, expectation.steps[s.step])
119
+
120
+ return detail === undefined
121
+ ? s
122
+ : { ...s, verdict: 'drifted' as const, detail }
123
+ })
124
+ const ran = new Set(steps.map((s) => s.step))
125
+ const dropped = Object.keys(expectation.steps)
126
+ .filter((name) => !ran.has(name))
127
+ .map((name): StepResult => ({
128
+ step: name,
129
+ argv: [],
130
+ exit: null,
131
+ verdict: 'drifted',
132
+ detail: `recorded but did not run, ${RECORD_HINT}`,
133
+ findings: [],
134
+ }))
135
+
136
+ return [...judged, ...dropped]
137
+ }
@@ -0,0 +1,323 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import {
3
+ copyFileSync,
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ rmSync,
11
+ } from 'node:fs'
12
+ import { dirname, join, relative } from 'node:path'
13
+ import { gitEnv } from '@/git/env'
14
+ import { EXPECTED_FILE } from '@/sandbox/replay/expected'
15
+ import {
16
+ findingsOf,
17
+ judgeFormat,
18
+ judgeStep,
19
+ type ReplayVerdict,
20
+ type StepOutput,
21
+ } from '@/sandbox/replay/verdict'
22
+ import { FIXTURE_SUFFIX, listFixtureFiles } from '@/sandbox/scenario'
23
+
24
+ export interface ReplaySource {
25
+ readonly name: string
26
+ readonly path: string
27
+ /**
28
+ * A snapshot is a folder of `.fixture` files with no history, committed
29
+ * fresh in the copy. A checkout is a consumer's real repository, copied
30
+ * whole with its history so its gitignored records come along.
31
+ */
32
+ readonly kind: 'snapshot' | 'checkout'
33
+ }
34
+
35
+ export interface ReplayContext {
36
+ /** The canon checkout whose CLI, formatter, and golden config the replay runs. */
37
+ readonly checkoutRoot: string
38
+ /** The folder each replay's temp tree is made under, outside every repository. */
39
+ readonly tempRoot: string
40
+ }
41
+
42
+ export interface StepResult {
43
+ readonly step: string
44
+ readonly argv: readonly string[]
45
+ readonly exit: number | null
46
+ readonly verdict: ReplayVerdict
47
+ readonly detail?: string
48
+ /** The `!` lines the step printed, reported whatever the verdict. */
49
+ readonly findings: readonly string[]
50
+ }
51
+
52
+ export interface ReplayResult {
53
+ readonly name: string
54
+ readonly kind: ReplaySource['kind']
55
+ readonly steps: readonly StepResult[]
56
+ /** Paths sync added or changed, relative to the tree, sorted. */
57
+ readonly written: readonly string[]
58
+ }
59
+
60
+ /**
61
+ * The sync, then the reads a consumer runs after one, each `--json` read a
62
+ * contract on its own stdout. The reads run on the unstaged result, which is
63
+ * the state a consumer's session meets before anyone commits the sync.
64
+ */
65
+ const CLI_STEPS: readonly { step: string; args: readonly string[] }[] = [
66
+ { step: 'sync', args: ['sync', '.'] },
67
+ { step: 'sync --check', args: ['sync', '--check', '.', '--json'] },
68
+ { step: 'context audit', args: ['context', 'audit', '--json'] },
69
+ { step: 'markdown audit', args: ['markdown', 'audit', '--json'] },
70
+ { step: 'migrate status', args: ['migrate', 'status', '--json'] },
71
+ ]
72
+
73
+ /** Each step took seconds on both consumers in the spike, so five minutes is a hang. */
74
+ const STEP_TIMEOUT_MS = 300_000
75
+
76
+ /**
77
+ * A checkout copy skips installs at any depth and the linked worktrees other
78
+ * sessions hold, neither of which a sync or a health read touches.
79
+ */
80
+ function isCopied(rel: string): boolean {
81
+ return rel.split('/').at(-1) !== 'node_modules' && rel !== '.claude/worktrees'
82
+ }
83
+
84
+ /**
85
+ * Copies a source into `tree`. A snapshot's files lose their `.fixture`
86
+ * suffix and its recorded expectation stays behind, and a checkout copies whole rather than cloning, since a clone
87
+ * drops the gitignored `.canon/` records a consumer's health verbs read.
88
+ */
89
+ export function copySource(source: ReplaySource, tree: string): void {
90
+ if (source.kind === 'checkout') {
91
+ cpSync(source.path, tree, {
92
+ recursive: true,
93
+ verbatimSymlinks: true,
94
+ filter: (from) => isCopied(relative(source.path, from)),
95
+ })
96
+ return
97
+ }
98
+
99
+ for (const file of listFixtureFiles(source.path)) {
100
+ if (relative(source.path, file) === EXPECTED_FILE) continue
101
+ if (!file.endsWith(FIXTURE_SUFFIX))
102
+ throw new Error(
103
+ `Snapshot file missing the ${FIXTURE_SUFFIX} suffix: ${file}`,
104
+ )
105
+ const target = join(
106
+ tree,
107
+ relative(source.path, file).slice(0, -FIXTURE_SUFFIX.length),
108
+ )
109
+ mkdirSync(dirname(target), { recursive: true })
110
+ copyFileSync(file, target)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Every child gets git's resolution variables stripped, since a pre-push hook
116
+ * exports `GIT_DIR` and a commit in the temp tree would otherwise land in the
117
+ * canon repository running the gate.
118
+ */
119
+ function childEnv(): NodeJS.ProcessEnv {
120
+ return { ...gitEnv(), CANON_NON_INTERACTIVE: '1', NO_COLOR: '1' }
121
+ }
122
+
123
+ function spawn(
124
+ command: string,
125
+ argv: readonly string[],
126
+ cwd: string,
127
+ json: boolean,
128
+ ): StepOutput {
129
+ const result = spawnSync(command, argv, {
130
+ cwd,
131
+ env: childEnv(),
132
+ encoding: 'utf8',
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ timeout: STEP_TIMEOUT_MS,
135
+ maxBuffer: 64 * 1024 * 1024,
136
+ })
137
+
138
+ return {
139
+ status: result.status,
140
+ signal: result.signal,
141
+ stdout: result.stdout ?? '',
142
+ stderr: result.stderr ?? result.error?.message ?? '',
143
+ json,
144
+ }
145
+ }
146
+
147
+ function git(tree: string, ...args: string[]): void {
148
+ const result = spawnSync(
149
+ 'git',
150
+ [
151
+ '-c',
152
+ 'user.name=canon-replay',
153
+ '-c',
154
+ 'user.email=replay@localhost',
155
+ '-c',
156
+ 'commit.gpgsign=false',
157
+ ...args,
158
+ ],
159
+ { cwd: tree, env: childEnv(), encoding: 'utf8' },
160
+ )
161
+ if (result.status !== 0)
162
+ throw new Error(
163
+ `git ${args.join(' ')} failed in the replay tree: ${result.stderr.trim()}`,
164
+ )
165
+ }
166
+
167
+ /** Every folder under the snapshot root, each one consumer's frozen managed paths. */
168
+ export function listSnapshots(dir: string): ReplaySource[] {
169
+ if (!existsSync(dir)) return []
170
+
171
+ return readdirSync(dir, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory())
173
+ .map((entry) => entry.name)
174
+ .sort()
175
+ .map((name) => ({ name, path: join(dir, name), kind: 'snapshot' }))
176
+ }
177
+
178
+ export interface SourceRefusal {
179
+ readonly reason: 'not-found' | 'not-a-repo' | 'dirty'
180
+ readonly message: string
181
+ }
182
+
183
+ /**
184
+ * Why a named checkout cannot be replayed, or undefined when it can. A dirty
185
+ * tree is refused rather than copied, since sync refuses one and the replay
186
+ * would then read a tree nobody synced, reporting the refusal as a clean run.
187
+ */
188
+ export function checkoutRefusal(path: string): SourceRefusal | undefined {
189
+ if (!existsSync(path))
190
+ return { reason: 'not-found', message: `No folder at ${path}` }
191
+
192
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
193
+ cwd: path,
194
+ env: childEnv(),
195
+ encoding: 'utf8',
196
+ })
197
+ if (top.status !== 0 || top.stdout.trim() !== realpathSync(path))
198
+ return {
199
+ reason: 'not-a-repo',
200
+ message: `${path} is not the root of a git repository`,
201
+ }
202
+
203
+ const status = spawnSync('git', ['status', '--porcelain'], {
204
+ cwd: path,
205
+ env: childEnv(),
206
+ encoding: 'utf8',
207
+ })
208
+ if (status.stdout.trim() !== '')
209
+ return {
210
+ reason: 'dirty',
211
+ message: `${path} has uncommitted changes. Commit or stash them, then replay it.`,
212
+ }
213
+
214
+ return undefined
215
+ }
216
+
217
+ /** Added, modified, and untracked paths after the sync. A deletion has nothing to format. */
218
+ function writtenPaths(tree: string): string[] {
219
+ const result = spawnSync(
220
+ 'git',
221
+ ['status', '--porcelain', '-z', '--untracked-files=all'],
222
+ {
223
+ cwd: tree,
224
+ env: childEnv(),
225
+ encoding: 'utf8',
226
+ },
227
+ )
228
+
229
+ return result.stdout
230
+ .split('\0')
231
+ .filter((entry) => entry.length > 3 && !entry.slice(0, 2).includes('D'))
232
+ .map((entry) => entry.slice(3))
233
+ .sort()
234
+ }
235
+
236
+ /**
237
+ * Copies one source into a fresh temp tree, syncs it with the checkout's own
238
+ * CLI, and runs the health reads and the format check on the unstaged
239
+ * result. The tree is removed whatever the steps conclude.
240
+ */
241
+ export function replaySource(
242
+ source: ReplaySource,
243
+ context: ReplayContext,
244
+ ): ReplayResult {
245
+ mkdirSync(context.tempRoot, { recursive: true })
246
+ const tree = mkdtempSync(join(context.tempRoot, `${source.name}-`))
247
+ const cli = join(context.checkoutRoot, 'src', 'cli.ts')
248
+ const steps: StepResult[] = []
249
+
250
+ try {
251
+ copySource(source, tree)
252
+ if (source.kind === 'snapshot') {
253
+ git(tree, 'init', '-q', '-b', 'main')
254
+ git(tree, 'add', '-A')
255
+ git(tree, 'commit', '-q', '-m', 'snapshot')
256
+ }
257
+
258
+ for (const { step, args } of CLI_STEPS) {
259
+ const output = spawn('bun', [cli, ...args], tree, args.includes('--json'))
260
+ steps.push({
261
+ step,
262
+ argv: ['canon', ...args],
263
+ exit: output.status,
264
+ ...judgeStep(output),
265
+ findings: findingsOf(output.stderr),
266
+ })
267
+ }
268
+
269
+ const written = writtenPaths(tree)
270
+ steps.push(formatStep(context.checkoutRoot, tree, written))
271
+
272
+ return { name: source.name, kind: source.kind, steps, written }
273
+ } finally {
274
+ rmSync(tree, { recursive: true, force: true })
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Runs the checkout's oxfmt over what sync wrote, under the golden config canon
280
+ * ships to every target rather than oxfmt's defaults, which quote YAML
281
+ * frontmatter differently from every consumer and would fail clean files.
282
+ */
283
+ function formatStep(
284
+ checkoutRoot: string,
285
+ tree: string,
286
+ written: readonly string[],
287
+ ): StepResult {
288
+ const config = join(
289
+ checkoutRoot,
290
+ 'tooling',
291
+ 'base',
292
+ 'configs',
293
+ '.oxfmtrc.json',
294
+ )
295
+ const args = [
296
+ '-c',
297
+ config,
298
+ '--check',
299
+ '--no-error-on-unmatched-pattern',
300
+ ...written,
301
+ ]
302
+ const argv = ['oxfmt', '--check', '<written>']
303
+ if (written.length === 0)
304
+ return { step: 'format', argv, exit: 0, verdict: 'ok', findings: [] }
305
+
306
+ const output = spawn(oxfmtBinary(checkoutRoot), args, tree, false)
307
+
308
+ return {
309
+ step: 'format',
310
+ argv,
311
+ exit: output.status,
312
+ ...judgeFormat(output),
313
+ findings: [],
314
+ }
315
+ }
316
+
317
+ function oxfmtBinary(checkoutRoot: string): string {
318
+ return join(checkoutRoot, 'node_modules', '.bin', 'oxfmt')
319
+ }
320
+
321
+ export function hasFormatter(checkoutRoot: string): boolean {
322
+ return existsSync(oxfmtBinary(checkoutRoot))
323
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What one step of a replay concluded. A finding or a refusal is the verb
3
+ * working, so it reads `ok`. Only a run that broke rather than answered fails:
4
+ * a `crash` threw or died, an `unreadable` run answered in a shape a caller
5
+ * cannot parse, and an `unformatted` tree holds a file sync wrote that the
6
+ * formatter canon ships rejects. A `drifted` read answered a frozen snapshot
7
+ * differently from the answer recorded beside it, which `judgeDrift` decides.
8
+ */
9
+ export type ReplayVerdict =
10
+ | 'ok'
11
+ | 'crash'
12
+ | 'unreadable'
13
+ | 'unformatted'
14
+ | 'drifted'
15
+
16
+ export interface StepOutput {
17
+ /** Null when the process ended on a signal, a timeout included. */
18
+ readonly status: number | null
19
+ readonly signal: string | null
20
+ readonly stdout: string
21
+ readonly stderr: string
22
+ /** Whether the step asked for `--json`, which makes stdout a contract. */
23
+ readonly json: boolean
24
+ }
25
+
26
+ export interface Judgement {
27
+ readonly verdict: ReplayVerdict
28
+ readonly detail?: string
29
+ }
30
+
31
+ /** A JavaScript stack frame, `at fn (file:line:col)` or a bare `at file:line:col`. */
32
+ const STACK_FRAME = /^\s+at .*:\d+:\d+\)?$/m
33
+
34
+ /** The line naming what was thrown, `TypeError: ...` or a system code such as `ENOENT: ...`. */
35
+ const THROWN_LINE = /^(?:error: |[A-Z][A-Za-z]*Error: |E[A-Z]+: ).*$/m
36
+
37
+ function crashOf(output: StepOutput): Judgement | undefined {
38
+ if (output.signal !== null)
39
+ return { verdict: 'crash', detail: `killed by ${output.signal}` }
40
+ if (!STACK_FRAME.test(output.stderr)) return undefined
41
+
42
+ return {
43
+ verdict: 'crash',
44
+ detail: THROWN_LINE.exec(output.stderr)?.[0] ?? 'stack trace on stderr',
45
+ }
46
+ }
47
+
48
+ export function judgeStep(output: StepOutput): Judgement {
49
+ const crash = crashOf(output)
50
+ if (crash !== undefined) return crash
51
+ if (!output.json) return { verdict: 'ok' }
52
+
53
+ try {
54
+ JSON.parse(output.stdout)
55
+ } catch {
56
+ const first = output.stdout.trim().split('\n')[0]
57
+
58
+ return {
59
+ verdict: 'unreadable',
60
+ detail:
61
+ first === undefined || first === ''
62
+ ? 'empty stdout'
63
+ : `stdout does not parse: ${first.slice(0, 120)}`,
64
+ }
65
+ }
66
+
67
+ return { verdict: 'ok' }
68
+ }
69
+
70
+ /**
71
+ * The warnings a step printed, each line the CLI marks `!` on a stream that is
72
+ * not a terminal. They never fail the replay, and carrying them is what lets a
73
+ * reader see a finding the verdict passed, such as a seed sync calls missing.
74
+ */
75
+ export function findingsOf(stderr: string): string[] {
76
+ return stderr
77
+ .split('\n')
78
+ .filter((line) => line.startsWith('! '))
79
+ .map((line) => line.slice(2).trim())
80
+ }
81
+
82
+ /** oxfmt lists each file it would change as `<path> (<n>ms)` under `--check`. */
83
+ const OXFMT_FILE = /^(\S.*) \(\d+m?s\)$/
84
+
85
+ export function judgeFormat(output: StepOutput): Judgement {
86
+ const crash = crashOf(output)
87
+ if (crash !== undefined) return crash
88
+ if (output.status === 0) return { verdict: 'ok' }
89
+
90
+ const files = output.stdout
91
+ .split('\n')
92
+ .map((line) => OXFMT_FILE.exec(line.trim())?.[1])
93
+ .filter((file): file is string => file !== undefined)
94
+
95
+ return {
96
+ verdict: 'unformatted',
97
+ detail:
98
+ files.length > 0
99
+ ? files.join(', ')
100
+ : output.stderr.trim().split('\n')[0] || `exit ${output.status}`,
101
+ }
102
+ }
@@ -14,8 +14,8 @@ 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
- // It is a context option rather than a top-level one, so written beside
18
- // `baseURL` it type-checks nowhere and reaches no page.
17
+ // A top-level `use.reducedMotion` works from Playwright 1.50.
18
+ // `contextOptions` is the spelling that works on every version.
19
19
  contextOptions: { reducedMotion: 'reduce' },
20
20
  },
21
21
  projects: [
@@ -12,8 +12,8 @@ export default defineConfig({
12
12
  trace: 'on-first-retry',
13
13
  baseURL,
14
14
  // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
15
- // It is a context option rather than a top-level one, so written beside
16
- // `baseURL` it type-checks nowhere and reaches no page.
15
+ // A top-level `use.reducedMotion` works from Playwright 1.50.
16
+ // `contextOptions` is the spelling that works on every version.
17
17
  contextOptions: { reducedMotion: 'reduce' },
18
18
  },
19
19
  projects: [
@@ -14,8 +14,8 @@ 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
- // It is a context option rather than a top-level one, so written beside
18
- // `baseURL` it type-checks nowhere and reaches no page.
17
+ // A top-level `use.reducedMotion` works from Playwright 1.50.
18
+ // `contextOptions` is the spelling that works on every version.
19
19
  contextOptions: { reducedMotion: 'reduce' },
20
20
  },
21
21
  projects: [