diff --git a/capture/README.md b/capture/README.md index f91068899..1f94f3ec1 100644 --- a/capture/README.md +++ b/capture/README.md @@ -46,6 +46,8 @@ Each SVG is scrubbed of ports, LAN addresses, home and temporary directories, an `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. +`capture/lib/fetch-stub.mjs` also applies a latency floor (`CAPTURE_FETCH_LATENCY`) where a scenario asks for one, so a request cannot outrun the spinner covering it and leave the recording without one. + `nuxt-dev-install-module` stubs everything the auto-install flow reaches for: the modules DB and the npm registry are answered from `capture/fixture-data/`, and `capture/fixture-data/fake-npm/` shadows `npm` on `PATH` to fake the install itself, so the recording needs no network and the fixture app is restored afterwards. ## Before and after comparisons diff --git a/capture/captures.config.ts b/capture/captures.config.ts index e2facd542..213d38c99 100644 --- a/capture/captures.config.ts +++ b/capture/captures.config.ts @@ -254,6 +254,10 @@ export const captures: Capture[] = [ animated: true, rows: 24, scrub: DEFAULT_SCRUB, + env: { + NODE_OPTIONS: `--import=${new URL('lib/fetch-stub.mjs', import.meta.url).href}`, + CAPTURE_FETCH_LATENCY: '250', + }, async drive({ session }) { await session.waitFor(/Which template/, 60_000) await session.wait(1200) diff --git a/capture/lib/fetch-stub.mjs b/capture/lib/fetch-stub.mjs index 45678d0cb..0cefe9868 100644 --- a/capture/lib/fetch-stub.mjs +++ b/capture/lib/fetch-stub.mjs @@ -4,21 +4,32 @@ // // CAPTURE_FETCH_STUBS is a JSON object mapping a URL prefix to the absolute // path of a JSON file served as the response body. +// +// CAPTURE_FETCH_LATENCY is a floor, in milliseconds, on how long every request +// takes. A prompt spinner is only ever drawn from its 80ms repaint timer, so a +// request that resolves sooner than that leaves no trace in the recording at +// all: the floor keeps the spinner on screen whatever the link is doing. import { readFileSync } from 'node:fs' const stubs = Object.entries(JSON.parse(process.env.CAPTURE_FETCH_STUBS ?? '{}')) +const latency = Number(process.env.CAPTURE_FETCH_LATENCY ?? 0) const realFetch = globalThis.fetch globalThis.fetch = async (input, init) => { const url = typeof input === 'string' ? input : input.url ?? String(input) + const settled = latency > 0 ? new Promise(resolve => setTimeout(resolve, latency)) : undefined for (const [prefix, file] of stubs) { if (url.startsWith(prefix)) { - return new Response(readFileSync(file, 'utf8'), { + const body = readFileSync(file, 'utf8') + await settled + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' }, }) } } - return realFetch(input, init) + const response = await realFetch(input, init) + await settled + return response } diff --git a/capture/lib/scrub.ts b/capture/lib/scrub.ts index 9e532e80d..c871dae09 100644 --- a/capture/lib/scrub.ts +++ b/capture/lib/scrub.ts @@ -81,6 +81,10 @@ const RULES: Record = { steps: [ { pattern: /[⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, replacement: () => '⠋' }, { pattern: /[◒◓◑]/g, replacement: () => '◐' }, + // A prompt spinner also animates a trailing run of up to three dots, so + // how many of them a recording caught is a function of how long the work + // took. The message itself is what identifies the line. + { pattern: /^(\s*◐\s.*?)\.{1,3}(\s*)$/g, replacement: match => match[1]! + match[2]! }, ], }, qr: { diff --git a/capture/output/nuxt-init.svg b/capture/output/nuxt-init.svg index a318e5af6..f1616c29f 100644 --- a/capture/output/nuxt-init.svg +++ b/capture/output/nuxt-init.svg @@ -16,12 +16,23 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} npm create nuxt - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P` - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app - .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No - Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template + .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No + Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! diff --git a/capture/output/nuxt-init.txt b/capture/output/nuxt-init.txt index e8aceb800..8f117bb26 100644 --- a/capture/output/nuxt-init.txt +++ b/capture/output/nuxt-init.txt @@ -1,4 +1,4 @@ -styles: 5e2e229d1874e510 +styles: e4d222f3658202ee .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. diff --git a/capture/record.ts b/capture/record.ts index 8dabb5039..63146b6ea 100644 --- a/capture/record.ts +++ b/capture/record.ts @@ -123,6 +123,19 @@ async function ensureDevServer(): Promise { await devServer.wait(1500) } +/** + * A capture's `NODE_OPTIONS` adds to the ambient value rather than replacing + * it: scenarios use it to preload a loader, while the environment may already + * carry options the recorded CLI needs to reach the network at all (proxy + * support, TLS roots) or to run at all (heap limits). + */ +function captureEnv(capture: Capture): Record | undefined { + if (!capture.env?.NODE_OPTIONS || !process.env.NODE_OPTIONS) { + return capture.env + } + return { ...capture.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS} ${capture.env.NODE_OPTIONS}` } +} + async function runCapture(capture: Capture): Promise { const columns = capture.columns ?? Number(values.columns) const rows = capture.rows ?? 24 @@ -142,7 +155,7 @@ async function runCapture(capture: Capture): Promise { cwd, columns, rows, - env: capture.env, + env: captureEnv(capture), }) // Whatever happens, the session must not outlive its capture: a leaked dev