Skip to content

Commit cd9735e

Browse files
authored
chore: add a terminal capture harness and scenario fixtures (#1461)
1 parent 552b923 commit cd9735e

39 files changed

Lines changed: 11335 additions & 3 deletions

.github/workflows/autofix.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
1717
with:
1818
persist-credentials: false
19+
fetch-depth: 0
1920

2021
- run: npm i -g --force corepack && corepack enable
2122

@@ -32,4 +33,29 @@ jobs:
3233
- name: 🔠 Lint project (+ fix)
3334
run: pnpm run lint:fix
3435

36+
- name: 🔍 Check whether captures are affected
37+
id: capture-paths
38+
run: |
39+
base="$(git merge-base "origin/${{ github.base_ref }}" HEAD)"
40+
if git diff --quiet "$base" HEAD -- packages capture; then
41+
echo "changed=false" >> "$GITHUB_OUTPUT"
42+
else
43+
echo "changed=true" >> "$GITHUB_OUTPUT"
44+
fi
45+
46+
- name: 💾 Restore capture fixture
47+
if: steps.capture-paths.outputs.changed == 'true'
48+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
49+
with:
50+
path: ~/.cache/nuxt-cli-capture
51+
key: capture-fixture-${{ runner.os }}-${{ hashFiles('capture/fixture/package.json') }}
52+
53+
- name: 🛠 Build project
54+
if: steps.capture-paths.outputs.changed == 'true'
55+
run: pnpm build
56+
57+
- name: 📼 Re-record terminal captures
58+
if: steps.capture-paths.outputs.changed == 'true'
59+
run: node --experimental-strip-types capture/record.ts
60+
3561
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # 7a166d7532b277f34e16238930461bf77f9d7ed8

capture/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Terminal captures
2+
3+
Records CLI sessions in a pty and renders them as SVGs with light and dark variants selected by `prefers-color-scheme`.
4+
5+
## Recording
6+
7+
Re-record everything:
8+
9+
```bash
10+
node capture/record.ts
11+
```
12+
13+
Re-record one scenario:
14+
15+
```bash
16+
node capture/record.ts --only nuxt-dev-restart
17+
```
18+
19+
The first time you run this, it will copy `capture/fixture/` into a work directory and install its dependencies, which takes a couple of minutes.
20+
21+
### Options
22+
23+
| Option | Default | Purpose |
24+
| --- | --- | --- |
25+
| `--only <id>` | all | Record one scenario. Repeatable. |
26+
| `--bin <path>` | `packages/nuxt-cli/bin/nuxi.mjs` | The CLI to record. Point this at another build to produce a before/after pair. |
27+
| `--workdir <path>` | `~/.cache/nuxt-cli-capture` | Where the fixture app is materialised. |
28+
| `--out <path>` | `capture/output` | Where SVGs are written. |
29+
| `--columns <n>` | `96` | Terminal width, unless the scenario sets its own. |
30+
| `--no-scrub` | off | Keep real ports, paths and hostnames, and skip the fingerprint gate. |
31+
| `--force` | off | Rewrite SVGs even when the fingerprint says the content is unchanged. |
32+
33+
## Scenarios
34+
35+
Defined in `captures.config.ts`, currently: `nuxt-dev`, `nuxt-dev-static`, `nuxt-dev-restart`, `nuxt-init`, `nuxt-curl`, `nuxt-task-list`, `nuxt-module-search`.
36+
37+
A scenario is a command plus an optional `drive` function that types into the session, edits a file, or waits for a pattern, so flows with several steps can be captured as one animation.
38+
39+
## Scrubbing
40+
41+
Each SVG is scrubbed of ports, LAN addresses, home and temporary directories, and (for dev scenarios) the QR code. Each capture's `<desc>` records which rules were applied.
42+
43+
## Stubbed APIs
44+
45+
`nuxt module search` is recorded against `capture/fixture-data/modules.json` instead of the live `api.nuxt.com`. You can refresh the fixture when the docs should show newer modules.
46+
47+
## Before and after comparisons
48+
49+
Record the same scenario against two builds by pointing `--bin` at each and `--out` at different directories:
50+
51+
```bash
52+
node capture/record.ts --only nuxt-dev --bin /path/to/old/bin/nuxi.mjs --out capture/output/before
53+
node capture/record.ts --only nuxt-dev --out capture/output/after
54+
```
55+
56+
## Requirements
57+
58+
`script` from util-linux, for the pty.

capture/captures.config.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { PtySession } from './lib/pty.ts'
2+
import { fileURLToPath } from 'node:url'
3+
4+
interface CaptureContext {
5+
session: PtySession
6+
/** Directory the capture runs in. */
7+
cwd: string
8+
/** Absolute path to `bin/nuxi.mjs` under test. */
9+
bin: string
10+
}
11+
12+
export interface Capture {
13+
id: string
14+
title: string
15+
/** Shell command run inside the pty. `$NUXT` expands to the CLI entry under test, `$CREATE_NUXT` to the workspace `create-nuxt` bin. */
16+
command: string
17+
/** Directory to run in, relative to the capture workdir. */
18+
cwd?: string
19+
columns?: number
20+
rows?: number
21+
animated?: boolean
22+
/** Rules from `lib/scrub.ts` applied to the captured bytes. */
23+
scrub?: string[]
24+
/** Drives the session: waits, keystrokes, file edits. */
25+
drive?: (context: CaptureContext) => Promise<void>
26+
/** Extra environment for the pty. */
27+
env?: Record<string, string>
28+
/** Kill the command once `drive` returns rather than waiting for it to exit. */
29+
stopAfterDrive?: boolean
30+
}
31+
32+
/**
33+
* Rendering scrubs are privacy and environment only: real timings, versions
34+
* and spinner phases stay in the SVG so it still reads as a real recording.
35+
* Whether a re-recording *replaces* a committed SVG is decided separately, by
36+
* a fingerprint that pins all of those (see `FINGERPRINT_RULES` in record.ts).
37+
*/
38+
const DEFAULT_SCRUB = ['ports', 'hostnames', 'paths']
39+
40+
/** Dev output also shows a QR code encoding the machine's real address. */
41+
const DEV_SCRUB = [...DEFAULT_SCRUB, 'qr']
42+
43+
export const captures: Capture[] = [
44+
{
45+
id: 'nuxt-dev',
46+
title: 'nuxt dev',
47+
command: '$NUXT dev --no-clear --takeover',
48+
cwd: 'app',
49+
animated: true,
50+
rows: 26,
51+
scrub: DEV_SCRUB,
52+
stopAfterDrive: true,
53+
async drive({ session }) {
54+
await session.waitFor(/warmed up|Vite client built/, 180_000)
55+
await session.wait(2500)
56+
},
57+
},
58+
{
59+
id: 'nuxt-dev-static',
60+
title: 'nuxt dev (ready)',
61+
command: '$NUXT dev --no-clear --takeover',
62+
cwd: 'app',
63+
animated: false,
64+
rows: 26,
65+
scrub: DEV_SCRUB,
66+
stopAfterDrive: true,
67+
async drive({ session }) {
68+
await session.waitFor(/warmed up|Vite client built/, 180_000)
69+
await session.wait(2000)
70+
},
71+
},
72+
{
73+
id: 'nuxt-dev-restart',
74+
title: 'nuxt dev (restart on config change)',
75+
command: '$NUXT dev --no-clear --takeover',
76+
cwd: 'app',
77+
animated: true,
78+
rows: 26,
79+
scrub: DEV_SCRUB,
80+
stopAfterDrive: true,
81+
async drive({ session, cwd }) {
82+
const { readFileSync, writeFileSync } = await import('node:fs')
83+
const { join } = await import('node:path')
84+
await session.waitFor(/warmed up|Vite client built/, 180_000)
85+
await session.wait(1500)
86+
const config = join(cwd, 'nuxt.config.ts')
87+
const original = readFileSync(config, 'utf8')
88+
try {
89+
writeFileSync(config, original.replace('compatibilityDate', `devtools: { enabled: false },\n compatibilityDate`))
90+
await session.waitFor(/Reloading Nuxt|Restarting Nuxt/, 30_000)
91+
await session.wait(4000)
92+
}
93+
finally {
94+
writeFileSync(config, original)
95+
}
96+
await session.wait(1000)
97+
},
98+
},
99+
{
100+
id: 'nuxt-init',
101+
title: 'npm create nuxt',
102+
command: '$CREATE_NUXT my-app --packageManager npm --no-install --no-gitInit',
103+
cwd: 'scratch',
104+
animated: true,
105+
rows: 24,
106+
scrub: DEFAULT_SCRUB,
107+
async drive({ session }) {
108+
await session.waitFor(/Which template/, 60_000)
109+
await session.wait(1200)
110+
session.send('\r')
111+
await session.waitFor(/browse and install modules/, 60_000)
112+
await session.wait(800)
113+
session.send('\r')
114+
await session.waitFor(/Happy building!/, 120_000)
115+
await session.wait(800)
116+
},
117+
},
118+
{
119+
id: 'nuxt-curl',
120+
title: 'nuxt curl',
121+
command: '$NUXT curl /api/hello -i',
122+
cwd: 'app',
123+
animated: false,
124+
rows: 16,
125+
scrub: DEFAULT_SCRUB,
126+
},
127+
{
128+
id: 'nuxt-task-list',
129+
title: 'nuxt task list',
130+
command: '$NUXT task list',
131+
cwd: 'app',
132+
animated: false,
133+
rows: 12,
134+
scrub: DEFAULT_SCRUB,
135+
},
136+
{
137+
id: 'nuxt-module-search',
138+
title: 'nuxt module search',
139+
command: '$NUXT module search image',
140+
cwd: 'app',
141+
animated: false,
142+
rows: 22,
143+
scrub: DEFAULT_SCRUB,
144+
// The live API drifts (star counts, new modules), so the capture would
145+
// invalidate on every re-record; answer it from committed fixture data.
146+
env: {
147+
NODE_OPTIONS: `--import=${new URL('lib/fetch-stub.mjs', import.meta.url).href}`,
148+
CAPTURE_FETCH_STUBS: JSON.stringify({
149+
'https://api.nuxt.com/modules': fileURLToPath(new URL('fixture-data/modules.json', import.meta.url)),
150+
}),
151+
},
152+
},
153+
]
154+
155+
/** Captures that need a dev server already running in the fixture. */
156+
export const NEEDS_DEV_SERVER = new Set(['nuxt-curl', 'nuxt-task-list'])

0 commit comments

Comments
 (0)