From 6dfedca20ef271d0af379face66fdc30e0b76495 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 14:20:11 +0000 Subject: [PATCH 1/6] feat: prototype packaged Ghostwright recordings on pull requests --- .github/actions/record-simple-cli/action.yml | 67 +++++ .github/workflows/ghostwright-recording.yml | 119 ++++++++ experiments/ghostwright/ci/README.md | 44 +++ .../ci/simple-cli/package-lock.json | 22 ++ .../ghostwright/ci/simple-cli/package.json | 6 + .../ghostwright/ci/simple-cli/test.mjs | 58 ++++ experiments/ghostwright/package.json | 8 +- experiments/ghostwright/spikes/recording.ts | 259 +----------------- experiments/ghostwright/src/recording.ts | 257 +++++++++++++++++ pnpm-lock.yaml | 10 +- 10 files changed, 586 insertions(+), 264 deletions(-) create mode 100644 .github/actions/record-simple-cli/action.yml create mode 100644 .github/workflows/ghostwright-recording.yml create mode 100644 experiments/ghostwright/ci/README.md create mode 100644 experiments/ghostwright/ci/simple-cli/package-lock.json create mode 100644 experiments/ghostwright/ci/simple-cli/package.json create mode 100644 experiments/ghostwright/ci/simple-cli/test.mjs create mode 100644 experiments/ghostwright/src/recording.ts diff --git a/.github/actions/record-simple-cli/action.yml b/.github/actions/record-simple-cli/action.yml new file mode 100644 index 0000000..310e272 --- /dev/null +++ b/.github/actions/record-simple-cli/action.yml @@ -0,0 +1,67 @@ +name: Record built Ghostwright CLI +description: Install the built tarball in an isolated consumer, test it, and upload its recording. +inputs: + package: + description: Absolute path to the built Ghostwright tarball + required: true + source-sha: + description: Commit used to build Ghostwright + required: true +outputs: + artifact-url: + description: Download URL for this run's recording + value: ${{ steps.upload.outputs.artifact-url }} + test-outcome: + description: Node test step outcome + value: ${{ steps.test.outcome }} +runs: + using: composite + steps: + - name: Prepare isolated consumer + id: prepare + shell: bash + env: + PACKAGE: ${{ inputs.package }} + run: | + set -euo pipefail + consumer="$RUNNER_TEMP/ghostwright-consumer" + mkdir -p "$consumer" "$RUNNER_TEMP/ghostwright-recordings" + cp "$GITHUB_WORKSPACE/experiments/ghostwright/ci/simple-cli/"* "$consumer/" + npm ci --prefix "$consumer" --ignore-scripts + npm install --prefix "$consumer" --ignore-scripts --no-save --package-lock=false "$PACKAGE" + - name: Test the packaged CLI and record + id: test + continue-on-error: true + shell: bash + env: + GHOSTWRIGHT_RECORD_DIR: ${{ runner.temp }}/ghostwright-recordings + GHOSTWRIGHT_SOURCE_SHA: ${{ inputs.source-sha }} + run: node --test "$RUNNER_TEMP/ghostwright-consumer/test.mjs" + - name: Upload recording and diagnostics + id: upload + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ghostwright-simple-cli-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ghostwright-recordings/ + if-no-files-found: warn + retention-days: 14 + - name: Summarize and preserve the test outcome + if: ${{ always() }} + shell: bash + env: + TEST_OUTCOME: ${{ steps.test.outcome }} + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + SOURCE_SHA: ${{ inputs.source-sha }} + run: | + { + printf '### Ghostwright: built-package CLI recording\n\n' + printf 'Source: `%s`\n\nTest: **%s**\n\n' "$SOURCE_SHA" "$TEST_OUTCOME" + if [ -n "$ARTIFACT_URL" ]; then + printf '[Download MP4, GIF, asciicast and trace](%s) (14-day retention)\n' "$ARTIFACT_URL" + else + printf 'No recording artifact was produced. See the job logs.\n' + fi + } >> "$GITHUB_STEP_SUMMARY" + test "$TEST_OUTCOME" = success + test -n "$ARTIFACT_URL" diff --git a/.github/workflows/ghostwright-recording.yml b/.github/workflows/ghostwright-recording.yml new file mode 100644 index 0000000..142e1bd --- /dev/null +++ b/.github/workflows/ghostwright-recording.yml @@ -0,0 +1,119 @@ +name: Ghostwright recording prototype + +on: + pull_request: + workflow_dispatch: + +permissions: {} + +concurrency: + group: ghostwright-recording-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + record: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + outputs: + artifact-url: ${{ steps.record.outputs.artifact-url }} + test-outcome: ${{ steps.record.outputs.test-outcome }} + source-sha: ${{ steps.source.outputs.sha }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Record the build revision + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.22.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.10 + - name: Install build and recording tools + run: | + set -euo pipefail + npm install -g pnpm@10.24.0 + sudo apt-get update -qq + sudo apt-get install -y musl-tools ncurses-bin ffmpeg fonts-dejavu-core + rustup toolchain install 1.90.0 --profile minimal --target x86_64-unknown-linux-musl + echo 'RUSTUP_TOOLCHAIN=1.90.0' >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/ghostwright-tools" + cd "$RUNNER_TEMP/ghostwright-tools" + curl --fail --location --retry 3 https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz -o zig.tar.xz + echo '02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 zig.tar.xz' | sha256sum -c - + tar -xf zig.tar.xz + echo "$RUNNER_TEMP/ghostwright-tools/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH" + curl --fail --location --retry 3 https://github.com/asciinema/agg/releases/download/v1.9.0/agg-x86_64-unknown-linux-musl -o agg + echo 'ddcbf6ca044c8ac3a434dcb9ee89fb9e3be87209982b7c2adb55f782e8f0f390 agg' | sha256sum -c - + chmod +x agg + echo "$RUNNER_TEMP/ghostwright-tools" >> "$GITHUB_PATH" + - name: Install Ghostwright dependencies only + run: pnpm --filter ghostwright... install --frozen-lockfile --ignore-scripts + - name: Build and verify native artifacts + working-directory: experiments/ghostwright + run: bun run setup + - name: Build and pack Ghostwright at this revision + working-directory: experiments/ghostwright + run: | + pnpm build + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP" + - name: Exercise the built package + id: record + uses: ./.github/actions/record-simple-cli + with: + package: ${{ runner.temp }}/ghostwright-0.1.0.tgz + source-sha: ${{ steps.source.outputs.sha }} + + report: + needs: record + # This prototype posts only for same-repository PRs. Forks retain job artifacts. + if: ${{ always() && !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + # No checkout, no downloaded PR artifacts, and no PR code runs in this job. + - name: Update recording comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + RECORDING_URL: ${{ needs.record.outputs.artifact-url }} + TEST_OUTCOME: ${{ needs.record.outputs.test-outcome }} + JOB_RESULT: ${{ needs.record.result }} + SOURCE_SHA: ${{ needs.record.outputs.source-sha }} + with: + script: | + const { owner, repo } = context.repo; + const number = context.payload.pull_request.number; + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + if (pr.head.sha !== context.payload.pull_request.head.sha) { + core.info('A newer commit superseded this recording; leaving its comment alone.'); + return; + } + const marker = ''; + const success = process.env.JOB_RESULT === 'success'; + const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '### Ghostwright CLI recording', + '', + `${success ? '✅' : '❌'} **Build + packaged CLI test: ${process.env.JOB_RESULT}**`, + `PR head: \`${pr.head.sha}\``, + `Built revision (PR merge commit): \`${process.env.SOURCE_SHA || 'setup failed'}\``, + `Test/encoding: **${process.env.TEST_OUTCOME || 'not run'}**`, + '', + process.env.RECORDING_URL + ? `[Download the recording](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` + : 'No recording was produced. See the build/test logs.', + '', + `[View Actions run](${run})`, + '', + 'This test installs the Ghostwright tarball built by this run and drives a real CLI through a PTY. No UI/TTY sibling checkouts are used.', + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); + const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); + if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number: number, body }); diff --git a/experiments/ghostwright/ci/README.md b/experiments/ghostwright/ci/README.md new file mode 100644 index 0000000..5d18401 --- /dev/null +++ b/experiments/ghostwright/ci/README.md @@ -0,0 +1,44 @@ +# Playground PR recording prototype + +`.github/workflows/ghostwright-recording.yml` builds the Ghostwright source at the +PR's checked-out merge commit, including native artifacts and the recording +plugin. It packs that build and invokes the local `record-simple-cli` action. +There are no sibling UI/TTY checkouts and no published Ghostwright dependency. + +The action copies `simple-cli` into a fresh directory under RUNNER_TEMP, installs +its locked runtime dependency and the generated tarball, and runs Node's test +runner. The test imports `ghostwright` and `ghostwright/recording` through the +installed package exports and asserts those imports resolve into package `dist/`. +It launches a real shell CLI, types Ada, checks the greeting and process exit, +and records the session with the packaged plugin. + +MP4, intermediate GIF, asciicast, JSON frames and source metadata are uploaded +with 14-day retention, even after a test/encoder failure. A failed test still +fails the check. Setup failures before capture can produce no recording. The +Actions job summary and PR comment link to the artifact download; GitHub artifact +links are downloads, not inline video attachments. + +The separate report job checks out no code, consumes only job result/URL outputs, +and updates one bot comment. Only same-repository PRs receive comments in this +prototype; forks and manual runs retain their Actions summaries/artifacts. The +report job verifies the PR head still matches the event before writing. It uses +pull-requests:write while the build/test job only has contents:read. This is a +same-repository prototype, not the planned trusted-default-branch publisher for +arbitrary fork PRs. + +Tool versions and download checksums are pinned in the workflow where practical. +FFmpeg, ncurses and fonts come from Ubuntu 24.04's package repositories. Native +artifacts are rebuilt from the pinned Ghostty source, patch and Rust lockfile; +the build refreshes artifact hashes in the packaged manifest and verifies its ABI. +No native build cache is used in this first prototype. + +The consumer lock pins Effection 4.2.0, matching the scope API used by Ghostwright. +This isolated test caught that the former package minimum (4.0.2) and workspace +lock (4.0.3) were incompatible even though the linked development graph worked. + +For a local run, build Ghostwright with `pnpm build` in its directory (after +building native artifacts), `npm pack` into a temporary directory, copy +`ci/simple-cli/*` into an empty consumer directory, run `npm ci`, then +`npm install --no-save --package-lock=false /absolute/path/ghostwright-0.1.0.tgz`. +Set `GHOSTWRIGHT_RECORD_DIR` to a writable directory and run `node --test test.mjs` +from that consumer. agg and FFmpeg must be on PATH. Use Node 24.22.0 as in CI. diff --git a/experiments/ghostwright/ci/simple-cli/package-lock.json b/experiments/ghostwright/ci/simple-cli/package-lock.json new file mode 100644 index 0000000..43fdd6c --- /dev/null +++ b/experiments/ghostwright/ci/simple-cli/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "ghostwright-ci-consumer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghostwright-ci-consumer", + "dependencies": { + "effection": "4.2.0" + } + }, + "node_modules/effection": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/effection/-/effection-4.2.0.tgz", + "integrity": "sha512-exjR8G1aFFcmNMUSsAZKsFJXB1K7CZQm6eKFcNyNnFOG7yEcVo81GLWGEadjheFIr0Mxvi6e40TzN+HyW/uyxw==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + } + } +} diff --git a/experiments/ghostwright/ci/simple-cli/package.json b/experiments/ghostwright/ci/simple-cli/package.json new file mode 100644 index 0000000..f4be355 --- /dev/null +++ b/experiments/ghostwright/ci/simple-cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "ghostwright-ci-consumer", + "private": true, + "type": "module", + "dependencies": { "effection": "4.2.0" } +} diff --git a/experiments/ghostwright/ci/simple-cli/test.mjs b/experiments/ghostwright/ci/simple-cli/test.mjs new file mode 100644 index 0000000..ff67f5e --- /dev/null +++ b/experiments/ghostwright/ci/simple-cli/test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { withTerminal } from 'ghostwright'; +import { recordingExtension } from 'ghostwright/recording'; + +test('built Ghostwright records a real CLI conversation', { timeout: 180_000 }, async () => { + const output = process.env.GHOSTWRIGHT_RECORD_DIR; + assert.ok(output, 'GHOSTWRIGHT_RECORD_DIR must select the artifact directory'); + const resolved = import.meta.resolve('ghostwright'); + assert.match(resolved, /node_modules\/ghostwright\/dist\/index\.js$/); + assert.match( + import.meta.resolve('ghostwright/recording'), + /node_modules\/ghostwright\/dist\/recording\.js$/, + ); + await mkdir(output, { recursive: true }); + await writeFile( + `${output}/source.json`, + JSON.stringify( + { + commit: process.env.GHOSTWRIGHT_SOURCE_SHA ?? 'local', + package: resolved, + node: process.version, + }, + null, + 2, + ), + ); + const recording = recordingExtension({ + output: `${output}/simple-cli`, + formats: ['mp4'], + stepMs: 800, + }); + await withTerminal( + { + command: '/bin/sh', + args: [ + '-c', + `printf 'What is your name? '; IFS= read -r name; printf '\\r\\nHello, %s!\\r\\n' "$name"`, + ], + viewport: { columns: 40, rows: 6 }, + extensions: [recording], + trace: 'off', + }, + async (terminal) => { + using _capture = recording; + const prompt = await terminal.screen.findByText('What is your name?'); + await terminal.keyboard.type('Ada'); + await terminal.keyboard.press('Enter'); + const greeting = await terminal.screen.findByText('Hello, Ada!'); + assert.equal(greeting.text(), 'Hello, Ada!'); + assert.equal(prompt.text(), 'What is your name?'); + const status = await terminal.process.waitForExit(); + assert.equal(status.exitCode, 0); + assert.equal(status.ptyEof, true); + }, + ); +}); diff --git a/experiments/ghostwright/package.json b/experiments/ghostwright/package.json index 569ade6..84f820f 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -24,6 +24,10 @@ "types": "./dist/types/index.d.ts", "import": "./dist/index.js" }, + "./recording": { + "types": "./dist/types/recording.d.ts", + "import": "./dist/recording.js" + }, "./async": { "types": "./dist/types/async.d.ts", "import": "./dist/async.js" @@ -51,7 +55,7 @@ }, "scripts": { "setup": "bun run build:artifacts && bun run verify:artifacts", - "build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts src/effection/index.ts src/runner-matchers.ts src/vitest.ts src/jest.ts --outdir dist --target node --format esm --packages external --splitting --sourcemap=external && tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts", + "build": "rm -rf dist && bun build src/index.ts src/recording.ts src/async.ts src/pty/protocol.ts src/effection/index.ts src/runner-matchers.ts src/vitest.ts src/jest.ts --outdir dist --target node --format esm --packages external --splitting --sourcemap=external && tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts", "fetch:ghostty": "bun scripts/fetch-ghostty.ts", "build:ghostty-vt": "bun scripts/build-ghostty-vt.ts", "build:host:rust": "bun scripts/build-host-rust.ts", @@ -68,7 +72,7 @@ "test:examples": "bun test examples" }, "dependencies": { - "effection": "^4.0.2" + "effection": "^4.2.0" }, "devDependencies": { "@jest/globals": "^30.2.0", diff --git a/experiments/ghostwright/spikes/recording.ts b/experiments/ghostwright/spikes/recording.ts index de202aa..e30f9e6 100644 --- a/experiments/ghostwright/spikes/recording.ts +++ b/experiments/ghostwright/spikes/recording.ts @@ -1,257 +1,2 @@ -/* oxlint-disable bombshell-dev/exported-function-async -- Pure frame serialization is synchronous. */ -import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { GhostwrightError } from '../src/errors.ts'; -import { promisify } from 'node:util'; -import type { ScreenSnapshot, CellStyle, TerminalColor } from '../src/types.ts'; - -import type { TerminalExtensionDefinition, TerminalInteraction } from '../src/types.ts'; - -export type RecordedInteraction = TerminalInteraction; -export type RecordingEvent = - | { at: number; kind: 'frame'; screen: ScreenSnapshot } - | { at: number; kind: 'interaction'; action: RecordedInteraction }; -export interface RecordingExportOptions { - /** Absolute or cwd-relative file prefix, without an extension. */ - path: string; - formats?: readonly ('gif' | 'mp4')[]; - /** Playback-only spacing. The test itself never sleeps. Zero preserves timing. */ - stepMs?: number; - agg?: string; - ffmpeg?: string; -} - -/** Bounded, session-local capture of the exact Ghostty cells used by assertions. */ -export class LocalRecording implements Disposable { - readonly #started = performance.now(); - readonly #events: RecordingEvent[] = []; - #stopped = false; - constructor( - initial: ScreenSnapshot, - private readonly detach: () => void, - private readonly maxEvents = 2000, - ) { - this.frame(initial); - } - frame(screen: ScreenSnapshot): void { - this.#append({ at: performance.now() - this.#started, kind: 'frame', screen }); - } - interaction(action: RecordedInteraction): void { - this.#append({ at: performance.now() - this.#started, kind: 'interaction', action }); - } - #append(event: RecordingEvent): void { - if (this.#stopped) return; - if (this.#events.length >= this.maxEvents) - throw new GhostwrightError({ - code: 'GW_RECORDING_LIMIT', - message: `Local recording exceeded ${this.maxEvents} events; use a smaller component journey`, - }); - this.#events.push(structuredClone(event)); - } - stop(): void { - if (this.#stopped) return; - this.#stopped = true; - this.detach(); - } - [Symbol.dispose](): void { - this.stop(); - } - async export(options: RecordingExportOptions): Promise { - this.stop(); - const prefix = fileURLToPath(pathToFileURL(options.path)); - await mkdir(new URL('.', pathToFileURL(prefix)), { recursive: true }); - const trace = `${prefix}.json`; - const cast = `${prefix}.cast`; - await writeFile( - trace, - JSON.stringify({ version: 1, clock: 'milliseconds', events: this.#events }), - ); - await writeFile(cast, toAsciicast(this.#events, options.stepMs ?? 450)); - const paths = [trace, cast]; - const formats = options.formats ?? []; - if (!formats.length) return paths; - // MP4 goes through the same GIF renderer in this spike. The cast/trace stay - // available even when an external encoder is missing or fails. - const gif = `${prefix}.gif`; - await encode(options.agg ?? process.env.GHOSTWRIGHT_AGG ?? 'agg', [ - '--quiet', - '--theme', - 'asciinema', - '--font-size', - '18', - '--fps-cap', - '30', - '--last-frame-duration', - '1.5', - cast, - gif, - ]); - paths.push(gif); - if (formats.includes('mp4')) { - const mp4 = `${prefix}.mp4`; - await encode(options.ffmpeg ?? process.env.GHOSTWRIGHT_FFMPEG ?? 'ffmpeg', [ - '-hide_banner', - '-loglevel', - 'error', - '-y', - '-i', - gif, - '-vf', - 'fps=30,pad=ceil(iw/2)*2:ceil(ih/2)*2', - '-c:v', - 'libx264', - '-pix_fmt', - 'yuv420p', - '-movflags', - '+faststart', - mp4, - ]); - paths.push(mp4); - } - return paths; - } -} - -async function encode(command: string, args: string[]): Promise { - try { - await promisify(execFile)(command, args, { timeout: 120_000, maxBuffer: 1024 * 1024 }); - } catch (cause) { - throw new GhostwrightError({ - code: 'GW_RECORDING_ENCODER', - message: `Recording encoder ${command} failed. Install agg/FFmpeg or set GHOSTWRIGHT_AGG/GHOSTWRIGHT_FFMPEG. The .cast and .json recording were saved.`, - cause, - }); - } -} - -/** Standard asciicast v2, with a fixed canvas covering every captured viewport. */ -export function toAsciicast(events: readonly RecordingEvent[], stepMs = 450): string { - if (!Number.isFinite(stepMs) || stepMs < 0) - throw new GhostwrightError({ - code: 'GW_RECORDING_OPTIONS', - message: 'stepMs must be nonnegative', - }); - const screens = events.filter((event) => event.kind === 'frame').map((event) => event.screen); - const width = Math.max(1, ...screens.map((screen) => screen.viewport.columns)); - const height = Math.max(1, ...screens.map((screen) => screen.viewport.rows)); - const lines = [JSON.stringify({ version: 2, width, height, env: { TERM: 'xterm-256color' } })]; - let offset = 0; - let lastStep = 0; - let lastFrame = ''; - for (const event of events) { - let time = event.at + offset; - if (event.kind === 'interaction') { - const added = Math.max(0, lastStep + stepMs - time); - offset += added; - time += added; - lastStep = time; - lines.push(JSON.stringify([time / 1000, 'm', JSON.stringify(event.action)])); - } else { - const frame = snapshotAnsi(event.screen); - if (frame === lastFrame) continue; - lastFrame = frame; - lines.push(JSON.stringify([time / 1000, 'o', frame])); - } - } - return `${lines.join('\n')}\n`; -} - -/** Serialize cells, not app state. No application OSC, hyperlinks, or graphics are replayed. */ -export function snapshotAnsi(screen: ScreenSnapshot): string { - let result = '\x1b[?25l\x1b[0m\x1b[2J\x1b[?7l'; - for (const line of screen.lines) { - let lastStyle = ''; - for (const cell of line.cells) { - if (cell.continuation) continue; - const style = sgr(cell.style); - if (style !== lastStyle) { - result += style; - lastStyle = style; - } - // Explicit positions preserve wide/combining cell placement across renderers. - result += `\x1b[${line.row + 1};${cell.column + 1}H${cell.text || ' '}`; - } - } - const cursor = screen.cursor; - const shape = cursor.shape === 'bar' ? 6 : cursor.shape === 'underline' ? 4 : 2; - return `${result}\x1b[0m\x1b[${cursor.row + 1};${cursor.column + 1}H\x1b[${shape} q${cursor.visible ? '\x1b[?25h' : ''}`; -} - -function sgr(style: CellStyle): string { - const codes = [0]; - if (style.bold) codes.push(1); - if (style.faint) codes.push(2); - if (style.italic) codes.push(3); - if (style.inverse) codes.push(7); - if (style.invisible) codes.push(8); - if (style.strikethrough) codes.push(9); - if (style.overline) codes.push(53); - const values = codes.map(String); - if (style.underline) values.push(`4:${style.underline}`); - values.push(color(style.foreground, 38), color(style.background, 48)); - if (style.underlineColor && style.underlineColor.kind !== 'default') - values.push(color(style.underlineColor, 58)); - return `\x1b[${values.join(';')}m`; -} -function color(value: TerminalColor, channel: number): string { - if (value.kind === 'default') return String(channel + 1); - if (value.kind === 'palette') return `${channel};5;${value.index}`; - return `${channel};2;${value.red};${value.green};${value.blue}`; -} - -export interface RecordingExtensionOptions extends Omit { - output: string; - enabled?: boolean; -} -export interface RecordingExtension extends TerminalExtensionDefinition, Disposable { - stop(): void; - export(): Promise; -} - -/** Session-owned recording; reuse configuration by creating a fresh extension per session. */ -export function recordingExtension(options: RecordingExtensionOptions): RecordingExtension { - let recording: LocalRecording | undefined; - let installed = false; - let exported: Promise | undefined; - const extension: RecordingExtension = { - id: 'ghostwright/recording', - setup(context) { - if (installed) - throw new GhostwrightError({ - code: 'GW_RECORDING_REUSE', - message: 'Create a fresh recording extension for each terminal', - }); - installed = true; - if (options.enabled === false) return; - const offFrame = context.onFrame((frame) => recording!.frame(frame)); - const offAction = context.onAction((action) => recording!.interaction(action)); - recording = new LocalRecording(context.current(), () => { - offFrame(); - offAction(); - }); - return async () => { - await extension.export(); - }; - }, - stop() { - recording?.stop(); - }, - [Symbol.dispose]() { - extension.stop(); - }, - export() { - if (!installed) - return Promise.reject( - new GhostwrightError({ - code: 'GW_RECORDING_UNINSTALLED', - message: 'Install the recording extension on a terminal before exporting', - }), - ); - return (exported ??= recording - ? recording.export({ ...options, path: options.output }) - : Promise.resolve([])); - }, - }; - return extension; -} +// Preserve the spike import path while the packaged plugin lives in src. +export * from '../src/recording.ts'; diff --git a/experiments/ghostwright/src/recording.ts b/experiments/ghostwright/src/recording.ts new file mode 100644 index 0000000..2797f6e --- /dev/null +++ b/experiments/ghostwright/src/recording.ts @@ -0,0 +1,257 @@ +/* oxlint-disable bombshell-dev/exported-function-async -- Pure frame serialization is synchronous. */ +import { execFile } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { GhostwrightError } from './errors.ts'; +import { promisify } from 'node:util'; +import type { ScreenSnapshot, CellStyle, TerminalColor } from './types.ts'; + +import type { TerminalExtensionDefinition, TerminalInteraction } from './types.ts'; + +export type RecordedInteraction = TerminalInteraction; +export type RecordingEvent = + | { at: number; kind: 'frame'; screen: ScreenSnapshot } + | { at: number; kind: 'interaction'; action: RecordedInteraction }; +export interface RecordingExportOptions { + /** Absolute or cwd-relative file prefix, without an extension. */ + path: string; + formats?: readonly ('gif' | 'mp4')[]; + /** Playback-only spacing. The test itself never sleeps. Zero preserves timing. */ + stepMs?: number; + agg?: string; + ffmpeg?: string; +} + +/** Bounded, session-local capture of the exact Ghostty cells used by assertions. */ +export class LocalRecording implements Disposable { + readonly #started = performance.now(); + readonly #events: RecordingEvent[] = []; + #stopped = false; + constructor( + initial: ScreenSnapshot, + private readonly detach: () => void, + private readonly maxEvents = 2000, + ) { + this.frame(initial); + } + frame(screen: ScreenSnapshot): void { + this.#append({ at: performance.now() - this.#started, kind: 'frame', screen }); + } + interaction(action: RecordedInteraction): void { + this.#append({ at: performance.now() - this.#started, kind: 'interaction', action }); + } + #append(event: RecordingEvent): void { + if (this.#stopped) return; + if (this.#events.length >= this.maxEvents) + throw new GhostwrightError({ + code: 'GW_RECORDING_LIMIT', + message: `Local recording exceeded ${this.maxEvents} events; use a smaller component journey`, + }); + this.#events.push(structuredClone(event)); + } + stop(): void { + if (this.#stopped) return; + this.#stopped = true; + this.detach(); + } + [Symbol.dispose](): void { + this.stop(); + } + async export(options: RecordingExportOptions): Promise { + this.stop(); + const prefix = fileURLToPath(pathToFileURL(options.path)); + await mkdir(new URL('.', pathToFileURL(prefix)), { recursive: true }); + const trace = `${prefix}.json`; + const cast = `${prefix}.cast`; + await writeFile( + trace, + JSON.stringify({ version: 1, clock: 'milliseconds', events: this.#events }), + ); + await writeFile(cast, toAsciicast(this.#events, options.stepMs ?? 450)); + const paths = [trace, cast]; + const formats = options.formats ?? []; + if (!formats.length) return paths; + // MP4 goes through the same GIF renderer in this spike. The cast/trace stay + // available even when an external encoder is missing or fails. + const gif = `${prefix}.gif`; + await encode(options.agg ?? process.env.GHOSTWRIGHT_AGG ?? 'agg', [ + '--quiet', + '--theme', + 'asciinema', + '--font-size', + '18', + '--fps-cap', + '30', + '--last-frame-duration', + '1.5', + cast, + gif, + ]); + paths.push(gif); + if (formats.includes('mp4')) { + const mp4 = `${prefix}.mp4`; + await encode(options.ffmpeg ?? process.env.GHOSTWRIGHT_FFMPEG ?? 'ffmpeg', [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-i', + gif, + '-vf', + 'fps=30,pad=ceil(iw/2)*2:ceil(ih/2)*2', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + mp4, + ]); + paths.push(mp4); + } + return paths; + } +} + +async function encode(command: string, args: string[]): Promise { + try { + await promisify(execFile)(command, args, { timeout: 120_000, maxBuffer: 1024 * 1024 }); + } catch (cause) { + throw new GhostwrightError({ + code: 'GW_RECORDING_ENCODER', + message: `Recording encoder ${command} failed. Install agg/FFmpeg or set GHOSTWRIGHT_AGG/GHOSTWRIGHT_FFMPEG. The .cast and .json recording were saved.`, + cause, + }); + } +} + +/** Standard asciicast v2, with a fixed canvas covering every captured viewport. */ +export function toAsciicast(events: readonly RecordingEvent[], stepMs = 450): string { + if (!Number.isFinite(stepMs) || stepMs < 0) + throw new GhostwrightError({ + code: 'GW_RECORDING_OPTIONS', + message: 'stepMs must be nonnegative', + }); + const screens = events.filter((event) => event.kind === 'frame').map((event) => event.screen); + const width = Math.max(1, ...screens.map((screen) => screen.viewport.columns)); + const height = Math.max(1, ...screens.map((screen) => screen.viewport.rows)); + const lines = [JSON.stringify({ version: 2, width, height, env: { TERM: 'xterm-256color' } })]; + let offset = 0; + let lastStep = 0; + let lastFrame = ''; + for (const event of events) { + let time = event.at + offset; + if (event.kind === 'interaction') { + const added = Math.max(0, lastStep + stepMs - time); + offset += added; + time += added; + lastStep = time; + lines.push(JSON.stringify([time / 1000, 'm', JSON.stringify(event.action)])); + } else { + const frame = snapshotAnsi(event.screen); + if (frame === lastFrame) continue; + lastFrame = frame; + lines.push(JSON.stringify([time / 1000, 'o', frame])); + } + } + return `${lines.join('\n')}\n`; +} + +/** Serialize cells, not app state. No application OSC, hyperlinks, or graphics are replayed. */ +export function snapshotAnsi(screen: ScreenSnapshot): string { + let result = '\x1b[?25l\x1b[0m\x1b[2J\x1b[?7l'; + for (const line of screen.lines) { + let lastStyle = ''; + for (const cell of line.cells) { + if (cell.continuation) continue; + const style = sgr(cell.style); + if (style !== lastStyle) { + result += style; + lastStyle = style; + } + // Explicit positions preserve wide/combining cell placement across renderers. + result += `\x1b[${line.row + 1};${cell.column + 1}H${cell.text || ' '}`; + } + } + const cursor = screen.cursor; + const shape = cursor.shape === 'bar' ? 6 : cursor.shape === 'underline' ? 4 : 2; + return `${result}\x1b[0m\x1b[${cursor.row + 1};${cursor.column + 1}H\x1b[${shape} q${cursor.visible ? '\x1b[?25h' : ''}`; +} + +function sgr(style: CellStyle): string { + const codes = [0]; + if (style.bold) codes.push(1); + if (style.faint) codes.push(2); + if (style.italic) codes.push(3); + if (style.inverse) codes.push(7); + if (style.invisible) codes.push(8); + if (style.strikethrough) codes.push(9); + if (style.overline) codes.push(53); + const values = codes.map(String); + if (style.underline) values.push(`4:${style.underline}`); + values.push(color(style.foreground, 38), color(style.background, 48)); + if (style.underlineColor && style.underlineColor.kind !== 'default') + values.push(color(style.underlineColor, 58)); + return `\x1b[${values.join(';')}m`; +} +function color(value: TerminalColor, channel: number): string { + if (value.kind === 'default') return String(channel + 1); + if (value.kind === 'palette') return `${channel};5;${value.index}`; + return `${channel};2;${value.red};${value.green};${value.blue}`; +} + +export interface RecordingExtensionOptions extends Omit { + output: string; + enabled?: boolean; +} +export interface RecordingExtension extends TerminalExtensionDefinition, Disposable { + stop(): void; + export(): Promise; +} + +/** Session-owned recording; reuse configuration by creating a fresh extension per session. */ +export function recordingExtension(options: RecordingExtensionOptions): RecordingExtension { + let recording: LocalRecording | undefined; + let installed = false; + let exported: Promise | undefined; + const extension: RecordingExtension = { + id: 'ghostwright/recording', + setup(context) { + if (installed) + throw new GhostwrightError({ + code: 'GW_RECORDING_REUSE', + message: 'Create a fresh recording extension for each terminal', + }); + installed = true; + if (options.enabled === false) return; + const offFrame = context.onFrame((frame) => recording!.frame(frame)); + const offAction = context.onAction((action) => recording!.interaction(action)); + recording = new LocalRecording(context.current(), () => { + offFrame(); + offAction(); + }); + return async () => { + await extension.export(); + }; + }, + stop() { + recording?.stop(); + }, + [Symbol.dispose]() { + extension.stop(); + }, + export() { + if (!installed) + return Promise.reject( + new GhostwrightError({ + code: 'GW_RECORDING_UNINSTALLED', + message: 'Install the recording extension on a terminal before exporting', + }), + ); + return (exported ??= recording + ? recording.export({ ...options, path: options.output }) + : Promise.resolve([])); + }, + }; + return extension; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7425968..6820480 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: experiments/ghostwright: dependencies: effection: - specifier: ^4.0.2 - version: 4.0.3 + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@jest/globals': specifier: ^30.2.0 @@ -2094,8 +2094,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - effection@4.0.3: - resolution: {integrity: sha512-bmzMdw1+q6d+O6J2iU314cXofWsZEDmeF2nqOjgOJe7R964RDJKamXdoO3iBvmMa7CJhgNcjZHow1ILzUP8Bjg==} + effection@4.2.0: + resolution: {integrity: sha512-exjR8G1aFFcmNMUSsAZKsFJXB1K7CZQm6eKFcNyNnFOG7yEcVo81GLWGEadjheFIr0Mxvi6e40TzN+HyW/uyxw==} engines: {node: '>= 16'} electron-to-chromium@1.5.422: @@ -4767,7 +4767,7 @@ snapshots: eastasianwidth@0.2.0: {} - effection@4.0.3: {} + effection@4.2.0: {} electron-to-chromium@1.5.422: {} From ecb8edcea0d8bd315d83acdf9b1506d2f1bb3609 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 14:30:02 +0000 Subject: [PATCH 2/6] Publish direct MP4 recording links on pull requests --- .github/workflows/ghostwright-recording.yml | 72 ++++++++++++++++++++- experiments/ghostwright/ci/README.md | 20 ++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ghostwright-recording.yml b/.github/workflows/ghostwright-recording.yml index 142e1bd..af208f2 100644 --- a/.github/workflows/ghostwright-recording.yml +++ b/.github/workflows/ghostwright-recording.yml @@ -75,11 +75,72 @@ jobs: runs-on: ubuntu-24.04 permissions: pull-requests: write + contents: write + actions: read steps: - # No checkout, no downloaded PR artifacts, and no PR code runs in this job. + # Media is treated as data. No checkout or execution of PR code in this job. + - name: Download recording media + id: media + if: ${{ needs.record.outputs.artifact-url != '' }} + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ghostwright-simple-cli-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/recording-media + - name: Publish direct MP4 link + id: video + if: ${{ steps.media.outcome == 'success' }} + continue-on-error: true + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('node:fs/promises'); + const path = require('node:path'); + const { owner, repo } = context.repo; + const number = context.payload.pull_request.number; + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number }); + if (pr.head.sha !== context.payload.pull_request.head.sha) return; + const file = path.join(process.env.RUNNER_TEMP, 'recording-media', 'simple-cli.mp4'); + let stat; + try { stat = await fs.lstat(file); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + core.info('No MP4 was produced; diagnostics remain in the Actions artifact.'); + return; + } + if (!stat.isFile() || stat.size === 0 || stat.size > 25 * 1024 * 1024) { + throw new Error('Expected a nonempty MP4 file of at most 25 MiB'); + } + const tag = `ghostwright-recordings-pr-${number}`; + let release; + try { + ({ data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag })); + } catch (error) { + if (error.status !== 404) throw error; + ({ data: release } = await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, target_commitish: pr.head.sha, + name: `Ghostwright recordings — PR #${number}`, + body: `CI recording media for PR #${number}. Assets are named by run and attempt. This prerelease is not a package release. Media remains until this release is deleted.`, + prerelease: true, make_latest: 'false', + })); + } + const name = `simple-cli-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`; + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, + }); + let asset = assets.find(asset => asset.name === name); + if (!asset) { + ({ data: asset } = await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id: release.id, name, + data: await fs.readFile(file), + headers: { 'content-type': 'video/mp4', 'content-length': stat.size }, + })); + } + core.setOutput('url', asset.browser_download_url); - name: Update recording comment uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: + MP4_URL: ${{ steps.video.outputs.url }} RECORDING_URL: ${{ needs.record.outputs.artifact-url }} TEST_OUTCOME: ${{ needs.record.outputs.test-outcome }} JOB_RESULT: ${{ needs.record.result }} @@ -105,8 +166,12 @@ jobs: `Built revision (PR merge commit): \`${process.env.SOURCE_SHA || 'setup failed'}\``, `Test/encoding: **${process.env.TEST_OUTCOME || 'not run'}**`, '', + process.env.MP4_URL + ? `[Download MP4](${process.env.MP4_URL})` + : 'Direct MP4 unavailable; check the artifact and publishing logs.', + '', process.env.RECORDING_URL - ? `[Download the recording](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` + ? `[Download all recording files](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` : 'No recording was produced. See the build/test logs.', '', `[View Actions run](${run})`, @@ -117,3 +182,6 @@ jobs: const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); else await github.rest.issues.createComment({ owner, repo, issue_number: number, body }); + - name: Preserve publishing failures + if: ${{ steps.media.outcome == 'failure' || steps.video.outcome == 'failure' }} + run: exit 1 diff --git a/experiments/ghostwright/ci/README.md b/experiments/ghostwright/ci/README.md index 5d18401..32a5e3b 100644 --- a/experiments/ghostwright/ci/README.md +++ b/experiments/ghostwright/ci/README.md @@ -15,14 +15,24 @@ and records the session with the packaged plugin. MP4, intermediate GIF, asciicast, JSON frames and source metadata are uploaded with 14-day retention, even after a test/encoder failure. A failed test still fails the check. Setup failures before capture can produce no recording. The -Actions job summary and PR comment link to the artifact download; GitHub artifact -links are downloads, not inline video attachments. +Actions job summary and PR comment link to the artifact download. The bot also +links directly to the MP4, published as a `video/mp4` asset on a dedicated +`ghostwright-recordings-pr-` prerelease. Each run/attempt gets a unique +filename. Release media remains until the release is deleted; the 14-day limit +applies only to Actions artifacts. The prerelease never becomes the latest release. -The separate report job checks out no code, consumes only job result/URL outputs, -and updates one bot comment. Only same-repository PRs receive comments in this +A direct release asset URL avoids downloading/unzipping the whole artifact. +GitHub's native inline comment player requires a GitHub video attachment; +release asset links are not native comment attachments. The supported release +API works with the workflow token and requires no browser session credentials. + +The separate report job checks out no code, downloads the current run's recording artifact as data, +publishes only the fixed MP4 filename (limited to 25 MiB), and updates one bot +comment. It never executes downloaded files. Missing MP4s retain diagnostics; +publishing failures still update the comment and then fail the report job. Only same-repository PRs receive comments in this prototype; forks and manual runs retain their Actions summaries/artifacts. The report job verifies the PR head still matches the event before writing. It uses -pull-requests:write while the build/test job only has contents:read. This is a +pull-requests:write, actions:read and contents:write (for release uploads), while the build/test job only has contents:read. This is a same-repository prototype, not the planned trusted-default-branch publisher for arbitrary fork PRs. From 2552bb5c22d427914e68144039aadcd3d464212e Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 14:34:30 +0000 Subject: [PATCH 3/6] Fix repository formatting checks --- .../ghostwright/ci/simple-cli/package.json | 10 +++--- experiments/ghostwright/spikes/RECORDING.md | 36 +++++++++---------- packages/clack-tty/tsconfig.json | 10 ++---- packages/pizza-preact/tsconfig.json | 11 ++---- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/experiments/ghostwright/ci/simple-cli/package.json b/experiments/ghostwright/ci/simple-cli/package.json index f4be355..0379246 100644 --- a/experiments/ghostwright/ci/simple-cli/package.json +++ b/experiments/ghostwright/ci/simple-cli/package.json @@ -1,6 +1,8 @@ { - "name": "ghostwright-ci-consumer", - "private": true, - "type": "module", - "dependencies": { "effection": "4.2.0" } + "name": "ghostwright-ci-consumer", + "private": true, + "type": "module", + "dependencies": { + "effection": "4.2.0" + } } diff --git a/experiments/ghostwright/spikes/RECORDING.md b/experiments/ghostwright/spikes/RECORDING.md index 336b89e..691bf6b 100644 --- a/experiments/ghostwright/spikes/RECORDING.md +++ b/experiments/ghostwright/spikes/RECORDING.md @@ -44,21 +44,21 @@ MP4 currently uses GIF as its intermediate, so MP4 mode saves both. import { recordTest } from './path/to/spikes/recording-vitest.ts'; test('increments a counter', async (context) => { - const recording = recordTest(context); - await using terminal = await createTerminal({ - extensions: [clackTtyExtension(), recording], - }); - await using ui = await createUI({ - ...terminal.appIO, - extensions: [useSemantic], - }); - using root = createRoot(ui.document.documentElement); - using _capture = recording; - - root.render(); - terminal.run(ui.main); - - // Existing keyboard, locator and terminal-evidence assertions go here. + const recording = recordTest(context); + await using terminal = await createTerminal({ + extensions: [clackTtyExtension(), recording], + }); + await using ui = await createUI({ + ...terminal.appIO, + extensions: [useSemantic], + }); + using root = createRoot(ui.document.documentElement); + using _capture = recording; + + root.render(); + terminal.run(ui.main); + + // Existing keyboard, locator and terminal-evidence assertions go here. }); ``` @@ -74,11 +74,11 @@ The runner-independent API uses the same extension: ```ts const recording = recordingExtension({ - output: '/tmp/counter', - formats: ['gif', 'mp4'], + output: '/tmp/counter', + formats: ['gif', 'mp4'], }); await using terminal = await createTerminal({ - extensions: [clackTtyExtension(), recording], + extensions: [clackTtyExtension(), recording], }); await using ui = await createUI({ ...terminal.appIO, extensions: [useSemantic] }); using _capture = recording; diff --git a/packages/clack-tty/tsconfig.json b/packages/clack-tty/tsconfig.json index bfc8a9b..73d277c 100644 --- a/packages/clack-tty/tsconfig.json +++ b/packages/clack-tty/tsconfig.json @@ -2,13 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "composite": false, - "types": [ - "node" - ] + "types": ["node"] }, - "include": [ - "src/**/*.ts", - "test/**/*.ts", - "vitest.config.ts" - ] + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] } diff --git a/packages/pizza-preact/tsconfig.json b/packages/pizza-preact/tsconfig.json index dee34d8..979ffd7 100644 --- a/packages/pizza-preact/tsconfig.json +++ b/packages/pizza-preact/tsconfig.json @@ -7,14 +7,7 @@ "jsx": "react-jsx", "jsxImportSource": "@clack/ui-preact", "noEmit": true, - "types": [ - "node" - ] + "types": ["node"] }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "test/**/*.ts", - "vitest.config.ts" - ] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vitest.config.ts"] } From 5906803f12d88d22713388e17d4b79b459b5f408 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 14:44:51 +0000 Subject: [PATCH 4/6] Upload native GitHub video attachments for inline PR playback --- .github/workflows/ghostwright-recording.yml | 48 +++++++++------------ experiments/ghostwright/ci/README.md | 44 +++++++++++-------- 2 files changed, 45 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ghostwright-recording.yml b/.github/workflows/ghostwright-recording.yml index af208f2..947426e 100644 --- a/.github/workflows/ghostwright-recording.yml +++ b/.github/workflows/ghostwright-recording.yml @@ -87,7 +87,7 @@ jobs: with: name: ghostwright-simple-cli-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/recording-media - - name: Publish direct MP4 link + - name: Upload MP4 as a GitHub attachment id: video if: ${{ steps.media.outcome == 'success' }} continue-on-error: true @@ -108,35 +108,27 @@ jobs: core.info('No MP4 was produced; diagnostics remain in the Actions artifact.'); return; } - if (!stat.isFile() || stat.size === 0 || stat.size > 25 * 1024 * 1024) { - throw new Error('Expected a nonempty MP4 file of at most 25 MiB'); + if (!stat.isFile() || stat.size === 0 || stat.size > 10 * 1024 * 1024) { + throw new Error('Expected a nonempty MP4 file of at most 10 MiB'); } - const tag = `ghostwright-recordings-pr-${number}`; - let release; - try { - ({ data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag })); - } catch (error) { - if (error.status !== 404) throw error; - ({ data: release } = await github.rest.repos.createRelease({ - owner, repo, tag_name: tag, target_commitish: pr.head.sha, - name: `Ghostwright recordings — PR #${number}`, - body: `CI recording media for PR #${number}. Assets are named by run and attempt. This prerelease is not a package release. Media remains until this release is deleted.`, - prerelease: true, make_latest: 'false', - })); - } - const name = `simple-cli-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`; - const assets = await github.paginate(github.rest.repos.listReleaseAssets, { - owner, repo, release_id: release.id, + // Same token-authenticated media endpoint used by gh --attach. + const query = new URLSearchParams({ + repository_id: String(context.payload.repository.id), + name: `simple-cli-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`, + content_type: 'video/mp4', }); - let asset = assets.find(asset => asset.name === name); - if (!asset) { - ({ data: asset } = await github.rest.repos.uploadReleaseAsset({ - owner, repo, release_id: release.id, name, + const { data: asset } = await github.request( + `POST https://uploads.github.com/user-attachments/assets?${query}`, + { data: await fs.readFile(file), - headers: { 'content-type': 'video/mp4', 'content-length': stat.size }, - })); + headers: { 'content-type': 'application/octet-stream', 'content-length': stat.size }, + }, + ); + const url = new URL(asset.url); + if (url.origin !== 'https://github.com' || !/^\/user-attachments\/assets\/[a-f0-9-]+$/.test(url.pathname)) { + throw new Error('Attachment upload returned an unexpected URL'); } - core.setOutput('url', asset.browser_download_url); + core.setOutput('url', url.href); - name: Update recording comment uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: @@ -167,8 +159,8 @@ jobs: `Test/encoding: **${process.env.TEST_OUTCOME || 'not run'}**`, '', process.env.MP4_URL - ? `[Download MP4](${process.env.MP4_URL})` - : 'Direct MP4 unavailable; check the artifact and publishing logs.', + ? process.env.MP4_URL + : 'Inline MP4 unavailable; check the artifact and publishing logs.', '', process.env.RECORDING_URL ? `[Download all recording files](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` diff --git a/experiments/ghostwright/ci/README.md b/experiments/ghostwright/ci/README.md index 32a5e3b..bdef25d 100644 --- a/experiments/ghostwright/ci/README.md +++ b/experiments/ghostwright/ci/README.md @@ -16,25 +16,31 @@ MP4, intermediate GIF, asciicast, JSON frames and source metadata are uploaded with 14-day retention, even after a test/encoder failure. A failed test still fails the check. Setup failures before capture can produce no recording. The Actions job summary and PR comment link to the artifact download. The bot also -links directly to the MP4, published as a `video/mp4` asset on a dedicated -`ghostwright-recordings-pr-` prerelease. Each run/attempt gets a unique -filename. Release media remains until the release is deleted; the 14-day limit -applies only to Actions artifacts. The prerelease never becomes the latest release. - -A direct release asset URL avoids downloading/unzipping the whole artifact. -GitHub's native inline comment player requires a GitHub video attachment; -release asset links are not native comment attachments. The supported release -API works with the workflow token and requires no browser session credentials. - -The separate report job checks out no code, downloads the current run's recording artifact as data, -publishes only the fixed MP4 filename (limited to 25 MiB), and updates one bot -comment. It never executes downloaded files. Missing MP4s retain diagnostics; -publishing failures still update the comment and then fail the report job. Only same-repository PRs receive comments in this -prototype; forks and manual runs retain their Actions summaries/artifacts. The -report job verifies the PR head still matches the event before writing. It uses -pull-requests:write, actions:read and contents:write (for release uploads), while the build/test job only has contents:read. This is a -same-repository prototype, not the planned trusted-default-branch publisher for -arbitrary fork PRs. +uploads the MP4 to GitHub attachment storage and places the returned URL in its +own paragraph, which GitHub renders as a native inline video player. Attachments +are separate from Actions artifacts and do not share their 14-day retention. +No release or tag is created for new recordings. + +GitHub documents this behavior in +[Attaching files with GitHub CLI](https://docs.github.com/en/github-cli/github-cli/attaching-files-with-github-cli). +The publisher calls the same token-authenticated media endpoint used by +`gh pr comment --attach`: `POST https://uploads.github.com/user-attachments/assets`, +with repository ID, filename and media type as query parameters and raw MP4 bytes +as the body. This avoids depending on the runner's installed CLI version. The +[gh-attach implementation](https://github.com/sudosubin/gh-attach/blob/e1d63f6c5fbe70a46fcf085035e39e8f422c2b4e/internal/github/attachments/token_uploader.go) +also demonstrates this endpoint. No browser cookies or separate PAT are configured. + +The separate report job checks out no code, downloads the current run's recording +artifact as data, publishes only the fixed MP4 filename (limited to 10 MiB for +compatibility with GitHub's free-plan limit), and updates one bot comment. It never +executes downloaded files. Missing MP4s retain diagnostics; publishing failures +still update the comment and then fail the report job. Only same-repository PRs +receive comments in this prototype; forks and manual runs retain their Actions +summaries/artifacts. The report job verifies the PR head still matches the event +before writing. It uses pull-requests:write, actions:read and contents:write +(attachment uploads require push access), while the build/test job only has +contents:read. This is a same-repository prototype, not the planned +trusted-default-branch publisher for arbitrary fork PRs. Tool versions and download checksums are pinned in the workflow where practical. FFmpeg, ncurses and fonts come from Ubuntu 24.04's package repositories. Native From c762d236fa49da236e83f1a85dfdb100663c979e Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 14:49:17 +0000 Subject: [PATCH 5/6] Configure user-token authentication for GitHub video attachments --- .github/workflows/ghostwright-recording.yml | 15 +++++++++++++-- experiments/ghostwright/ci/README.md | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ghostwright-recording.yml b/.github/workflows/ghostwright-recording.yml index 947426e..47bcba7 100644 --- a/.github/workflows/ghostwright-recording.yml +++ b/.github/workflows/ghostwright-recording.yml @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-24.04 permissions: pull-requests: write - contents: write + contents: read actions: read steps: # Media is treated as data. No checkout or execution of PR code in this job. @@ -92,8 +92,16 @@ jobs: if: ${{ steps.media.outcome == 'success' }} continue-on-error: true uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + ATTACHMENT_TOKEN_CONFIGURED: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN != '' }} with: + # Uploads require a user token; GITHUB_TOKEN is an unsupported installation token. + github-token: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN || github.token }} script: | + if (process.env.ATTACHMENT_TOKEN_CONFIGURED !== 'true') { + core.setFailed('Configure GHOSTWRIGHT_ATTACHMENT_TOKEN: GitHub attachments require a user token with repository write access; GITHUB_TOKEN cannot upload them.'); + return; + } const fs = require('node:fs/promises'); const path = require('node:path'); const { owner, repo } = context.repo; @@ -133,6 +141,7 @@ jobs: uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: MP4_URL: ${{ steps.video.outputs.url }} + ATTACHMENT_TOKEN_CONFIGURED: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN != '' }} RECORDING_URL: ${{ needs.record.outputs.artifact-url }} TEST_OUTCOME: ${{ needs.record.outputs.test-outcome }} JOB_RESULT: ${{ needs.record.result }} @@ -160,7 +169,9 @@ jobs: '', process.env.MP4_URL ? process.env.MP4_URL - : 'Inline MP4 unavailable; check the artifact and publishing logs.', + : process.env.ATTACHMENT_TOKEN_CONFIGURED !== 'true' + ? 'Inline video needs the `GHOSTWRIGHT_ATTACHMENT_TOKEN` repository secret. The recording is available in the artifact below.' + : 'Inline MP4 upload failed; check the artifact and publishing logs.', '', process.env.RECORDING_URL ? `[Download all recording files](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` diff --git a/experiments/ghostwright/ci/README.md b/experiments/ghostwright/ci/README.md index bdef25d..ed1b7d8 100644 --- a/experiments/ghostwright/ci/README.md +++ b/experiments/ghostwright/ci/README.md @@ -28,7 +28,19 @@ The publisher calls the same token-authenticated media endpoint used by with repository ID, filename and media type as query parameters and raw MP4 bytes as the body. This avoids depending on the runner's installed CLI version. The [gh-attach implementation](https://github.com/sudosubin/gh-attach/blob/e1d63f6c5fbe70a46fcf085035e39e8f422c2b4e/internal/github/attachments/token_uploader.go) -also demonstrates this endpoint. No browser cookies or separate PAT are configured. +also demonstrates this endpoint. No browser cookies are needed. GitHub's upload endpoint does **not** accept the +Actions `GITHUB_TOKEN` (an installation token): the live CI attempt returned 404. +The [GitHub CLI token allowlist](https://github.com/cli/cli/blob/trunk/internal/attachments/client.go) +accepts OAuth, classic/fine-grained PATs and some user-to-server tokens instead. + +Configure a repository Actions secret named `GHOSTWRIGHT_ATTACHMENT_TOKEN` with +a user token whose owner has write access to playground. Prefer a fine-grained +PAT restricted to this repository, with Contents: read/write and Pull requests: +read (the upload step also verifies the current PR head). This secret is used +only by the upload step in the same-repository report job, never the test job. +The comment step retains `GITHUB_TOKEN`, so comments remain authored by the bot. +Without the secret, the report fails with an explicit setup message while keeping +the test recording available as an artifact. The separate report job checks out no code, downloads the current run's recording artifact as data, publishes only the fixed MP4 filename (limited to 10 MiB for @@ -37,9 +49,8 @@ executes downloaded files. Missing MP4s retain diagnostics; publishing failures still update the comment and then fail the report job. Only same-repository PRs receive comments in this prototype; forks and manual runs retain their Actions summaries/artifacts. The report job verifies the PR head still matches the event -before writing. It uses pull-requests:write, actions:read and contents:write -(attachment uploads require push access), while the build/test job only has -contents:read. This is a same-repository prototype, not the planned +before writing. Its default token uses pull-requests:write, actions:read and contents:read, +while the build/test job only has contents:read. This is a same-repository prototype, not the planned trusted-default-branch publisher for arbitrary fork PRs. Tool versions and download checksums are pinned in the workflow where practical. From 47f2ff4365aa2744d224dbc9d4e25e7368e36c49 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 21 Oct 2026 15:16:32 +0000 Subject: [PATCH 6/6] Record Vim in packaged CI tests and restore MP4 download links --- .github/actions/record-simple-cli/action.yml | 11 +- .github/workflows/ghostwright-recording.yml | 100 ++++++++-------- experiments/ghostwright/ci/README.md | 73 +++++------- .../ghostwright/ci/simple-cli/vim.test.mjs | 4 + .../examples/vim-netrw/journey.mjs | 110 +++++++++++++++++ .../ghostwright/examples/vim-netrw/netrw.ts | 2 +- .../vim-netrw/normal-skills.vitest.ts | 111 +----------------- 7 files changed, 210 insertions(+), 201 deletions(-) create mode 100644 experiments/ghostwright/ci/simple-cli/vim.test.mjs create mode 100644 experiments/ghostwright/examples/vim-netrw/journey.mjs diff --git a/.github/actions/record-simple-cli/action.yml b/.github/actions/record-simple-cli/action.yml index 310e272..6dfce28 100644 --- a/.github/actions/record-simple-cli/action.yml +++ b/.github/actions/record-simple-cli/action.yml @@ -1,5 +1,5 @@ -name: Record built Ghostwright CLI -description: Install the built tarball in an isolated consumer, test it, and upload its recording. +name: Record built Ghostwright CLI and Vim +description: Install the built tarball in an isolated consumer, test CLI and Vim journeys, and upload their recordings. inputs: package: description: Absolute path to the built Ghostwright tarball @@ -27,6 +27,7 @@ runs: consumer="$RUNNER_TEMP/ghostwright-consumer" mkdir -p "$consumer" "$RUNNER_TEMP/ghostwright-recordings" cp "$GITHUB_WORKSPACE/experiments/ghostwright/ci/simple-cli/"* "$consumer/" + cp "$GITHUB_WORKSPACE/experiments/ghostwright/examples/vim-netrw/"{journey.mjs,netrw.ts} "$consumer/" npm ci --prefix "$consumer" --ignore-scripts npm install --prefix "$consumer" --ignore-scripts --no-save --package-lock=false "$PACKAGE" - name: Test the packaged CLI and record @@ -36,13 +37,13 @@ runs: env: GHOSTWRIGHT_RECORD_DIR: ${{ runner.temp }}/ghostwright-recordings GHOSTWRIGHT_SOURCE_SHA: ${{ inputs.source-sha }} - run: node --test "$RUNNER_TEMP/ghostwright-consumer/test.mjs" + run: node --test "$RUNNER_TEMP/ghostwright-consumer/test.mjs" "$RUNNER_TEMP/ghostwright-consumer/vim.test.mjs" - name: Upload recording and diagnostics id: upload if: ${{ always() }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: ghostwright-simple-cli-${{ github.run_id }}-${{ github.run_attempt }} + name: ghostwright-cli-vim-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/ghostwright-recordings/ if-no-files-found: warn retention-days: 14 @@ -55,7 +56,7 @@ runs: SOURCE_SHA: ${{ inputs.source-sha }} run: | { - printf '### Ghostwright: built-package CLI recording\n\n' + printf '### Ghostwright: built-package CLI and Vim recordings\n\n' printf 'Source: `%s`\n\nTest: **%s**\n\n' "$SOURCE_SHA" "$TEST_OUTCOME" if [ -n "$ARTIFACT_URL" ]; then printf '[Download MP4, GIF, asciicast and trace](%s) (14-day retention)\n' "$ARTIFACT_URL" diff --git a/.github/workflows/ghostwright-recording.yml b/.github/workflows/ghostwright-recording.yml index 47bcba7..3c574bc 100644 --- a/.github/workflows/ghostwright-recording.yml +++ b/.github/workflows/ghostwright-recording.yml @@ -38,7 +38,7 @@ jobs: set -euo pipefail npm install -g pnpm@10.24.0 sudo apt-get update -qq - sudo apt-get install -y musl-tools ncurses-bin ffmpeg fonts-dejavu-core + sudo apt-get install -y musl-tools ncurses-bin ffmpeg fonts-dejavu-core vim rustup toolchain install 1.90.0 --profile minimal --target x86_64-unknown-linux-musl echo 'RUSTUP_TOOLCHAIN=1.90.0' >> "$GITHUB_ENV" mkdir -p "$RUNNER_TEMP/ghostwright-tools" @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-24.04 permissions: pull-requests: write - contents: read + contents: write actions: read steps: # Media is treated as data. No checkout or execution of PR code in this job. @@ -85,63 +85,70 @@ jobs: continue-on-error: true uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: ghostwright-simple-cli-${{ github.run_id }}-${{ github.run_attempt }} + name: ghostwright-cli-vim-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/recording-media - - name: Upload MP4 as a GitHub attachment + - name: Publish direct MP4 links id: video if: ${{ steps.media.outcome == 'success' }} continue-on-error: true uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - env: - ATTACHMENT_TOKEN_CONFIGURED: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN != '' }} with: - # Uploads require a user token; GITHUB_TOKEN is an unsupported installation token. - github-token: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN || github.token }} script: | - if (process.env.ATTACHMENT_TOKEN_CONFIGURED !== 'true') { - core.setFailed('Configure GHOSTWRIGHT_ATTACHMENT_TOKEN: GitHub attachments require a user token with repository write access; GITHUB_TOKEN cannot upload them.'); - return; - } const fs = require('node:fs/promises'); const path = require('node:path'); const { owner, repo } = context.repo; const number = context.payload.pull_request.number; const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number }); if (pr.head.sha !== context.payload.pull_request.head.sha) return; - const file = path.join(process.env.RUNNER_TEMP, 'recording-media', 'simple-cli.mp4'); - let stat; - try { stat = await fs.lstat(file); } - catch (error) { - if (error.code !== 'ENOENT') throw error; - core.info('No MP4 was produced; diagnostics remain in the Actions artifact.'); - return; + const media = []; + for (const stem of ['simple-cli', 'vim-normal-skills']) { + const file = path.join(process.env.RUNNER_TEMP, 'recording-media', `${stem}.mp4`); + let stat; + try { stat = await fs.lstat(file); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + core.info(`No ${stem} MP4 was produced; see the Actions artifact.`); + continue; + } + if (!stat.isFile() || stat.size === 0 || stat.size > 25 * 1024 * 1024) { + throw new Error(`Expected ${stem} MP4 to be a nonempty file of at most 25 MiB`); + } + media.push({ stem, file, size: stat.size }); } - if (!stat.isFile() || stat.size === 0 || stat.size > 10 * 1024 * 1024) { - throw new Error('Expected a nonempty MP4 file of at most 10 MiB'); + if (media.length === 0) return; + const tag = `ghostwright-recordings-pr-${number}`; + let release; + try { + ({ data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag })); + } catch (error) { + if (error.status !== 404) throw error; + ({ data: release } = await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, target_commitish: pr.head.sha, + name: `Ghostwright recordings — PR #${number}`, + body: `CI recording media for PR #${number}. Assets are named by run and attempt. This prerelease is not a package release. Media remains until this release is deleted.`, + prerelease: true, make_latest: 'false', + })); } - // Same token-authenticated media endpoint used by gh --attach. - const query = new URLSearchParams({ - repository_id: String(context.payload.repository.id), - name: `simple-cli-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`, - content_type: 'video/mp4', + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, }); - const { data: asset } = await github.request( - `POST https://uploads.github.com/user-attachments/assets?${query}`, - { - data: await fs.readFile(file), - headers: { 'content-type': 'application/octet-stream', 'content-length': stat.size }, - }, - ); - const url = new URL(asset.url); - if (url.origin !== 'https://github.com' || !/^\/user-attachments\/assets\/[a-f0-9-]+$/.test(url.pathname)) { - throw new Error('Attachment upload returned an unexpected URL'); + for (const { stem, file, size } of media) { + const name = `${stem}-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`; + let asset = assets.find(asset => asset.name === name); + if (!asset) { + ({ data: asset } = await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id: release.id, name, + data: await fs.readFile(file), + headers: { 'content-type': 'video/mp4', 'content-length': size }, + })); + } + core.setOutput(stem, asset.browser_download_url); } - core.setOutput('url', url.href); - name: Update recording comment uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: - MP4_URL: ${{ steps.video.outputs.url }} - ATTACHMENT_TOKEN_CONFIGURED: ${{ secrets.GHOSTWRIGHT_ATTACHMENT_TOKEN != '' }} + MP4_URL: ${{ steps.video.outputs.simple-cli }} + VIM_URL: ${{ steps.video.outputs.vim-normal-skills }} RECORDING_URL: ${{ needs.record.outputs.artifact-url }} TEST_OUTCOME: ${{ needs.record.outputs.test-outcome }} JOB_RESULT: ${{ needs.record.result }} @@ -160,18 +167,19 @@ jobs: const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; const body = [ marker, - '### Ghostwright CLI recording', + '### Ghostwright CLI and Vim recordings', '', - `${success ? '✅' : '❌'} **Build + packaged CLI test: ${process.env.JOB_RESULT}**`, + `${success ? '✅' : '❌'} **Build + packaged CLI/Vim tests: ${process.env.JOB_RESULT}**`, `PR head: \`${pr.head.sha}\``, `Built revision (PR merge commit): \`${process.env.SOURCE_SHA || 'setup failed'}\``, `Test/encoding: **${process.env.TEST_OUTCOME || 'not run'}**`, '', process.env.MP4_URL - ? process.env.MP4_URL - : process.env.ATTACHMENT_TOKEN_CONFIGURED !== 'true' - ? 'Inline video needs the `GHOSTWRIGHT_ATTACHMENT_TOKEN` repository secret. The recording is available in the artifact below.' - : 'Inline MP4 upload failed; check the artifact and publishing logs.', + ? `[Download CLI MP4](${process.env.MP4_URL})` + : 'CLI MP4 unavailable; check the artifact and publishing logs.', + process.env.VIM_URL + ? `[Download Vim MP4](${process.env.VIM_URL})` + : 'Vim MP4 unavailable; check the artifact and publishing logs.', '', process.env.RECORDING_URL ? `[Download all recording files](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.` @@ -179,7 +187,7 @@ jobs: '', `[View Actions run](${run})`, '', - 'This test installs the Ghostwright tarball built by this run and drives a real CLI through a PTY. No UI/TTY sibling checkouts are used.', + 'These tests install the Ghostwright tarball built by this run and drive a real CLI and Vim/netrw through PTYs. No UI/TTY sibling checkouts are used.', ].join('\n'); const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number }); const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); diff --git a/experiments/ghostwright/ci/README.md b/experiments/ghostwright/ci/README.md index ed1b7d8..fab0678 100644 --- a/experiments/ghostwright/ci/README.md +++ b/experiments/ghostwright/ci/README.md @@ -6,55 +6,45 @@ plugin. It packs that build and invokes the local `record-simple-cli` action. There are no sibling UI/TTY checkouts and no published Ghostwright dependency. The action copies `simple-cli` into a fresh directory under RUNNER_TEMP, installs +the shared Vim journey (`examples/vim-netrw/journey.mjs` and `netrw.ts`), its locked runtime dependency and the generated tarball, and runs Node's test runner. The test imports `ghostwright` and `ghostwright/recording` through the installed package exports and asserts those imports resolve into package `dist/`. It launches a real shell CLI, types Ada, checks the greeting and process exit, -and records the session with the packaged plugin. +and records the session with the packaged plugin. A second Node test runs the +same Vim journey as `pnpm record:vim`: open netrw, locate `refactor.ts` from +painted screen geometry, edit with text objects, repeat, uppercase, yank/put, +increment, undo/redo, then save and exit. Both examples import the built package. +Run a package build before using the local Vim example after source changes. MP4, intermediate GIF, asciicast, JSON frames and source metadata are uploaded with 14-day retention, even after a test/encoder failure. A failed test still fails the check. Setup failures before capture can produce no recording. The Actions job summary and PR comment link to the artifact download. The bot also -uploads the MP4 to GitHub attachment storage and places the returned URL in its -own paragraph, which GitHub renders as a native inline video player. Attachments -are separate from Actions artifacts and do not share their 14-day retention. -No release or tag is created for new recordings. - -GitHub documents this behavior in -[Attaching files with GitHub CLI](https://docs.github.com/en/github-cli/github-cli/attaching-files-with-github-cli). -The publisher calls the same token-authenticated media endpoint used by -`gh pr comment --attach`: `POST https://uploads.github.com/user-attachments/assets`, -with repository ID, filename and media type as query parameters and raw MP4 bytes -as the body. This avoids depending on the runner's installed CLI version. The -[gh-attach implementation](https://github.com/sudosubin/gh-attach/blob/e1d63f6c5fbe70a46fcf085035e39e8f422c2b4e/internal/github/attachments/token_uploader.go) -also demonstrates this endpoint. No browser cookies are needed. GitHub's upload endpoint does **not** accept the -Actions `GITHUB_TOKEN` (an installation token): the live CI attempt returned 404. -The [GitHub CLI token allowlist](https://github.com/cli/cli/blob/trunk/internal/attachments/client.go) -accepts OAuth, classic/fine-grained PATs and some user-to-server tokens instead. - -Configure a repository Actions secret named `GHOSTWRIGHT_ATTACHMENT_TOKEN` with -a user token whose owner has write access to playground. Prefer a fine-grained -PAT restricted to this repository, with Contents: read/write and Pull requests: -read (the upload step also verifies the current PR head). This secret is used -only by the upload step in the same-repository report job, never the test job. -The comment step retains `GITHUB_TOKEN`, so comments remain authored by the bot. -Without the secret, the report fails with an explicit setup message while keeping -the test recording available as an artifact. - -The separate report job checks out no code, downloads the current run's recording -artifact as data, publishes only the fixed MP4 filename (limited to 10 MiB for -compatibility with GitHub's free-plan limit), and updates one bot comment. It never -executes downloaded files. Missing MP4s retain diagnostics; publishing failures -still update the comment and then fail the report job. Only same-repository PRs -receive comments in this prototype; forks and manual runs retain their Actions -summaries/artifacts. The report job verifies the PR head still matches the event -before writing. Its default token uses pull-requests:write, actions:read and contents:read, -while the build/test job only has contents:read. This is a same-repository prototype, not the planned -trusted-default-branch publisher for arbitrary fork PRs. +links directly to both MP4s, published as `video/mp4` assets on a dedicated +`ghostwright-recordings-pr-` prerelease. Each run/attempt gets a unique +filename. Release media remains until the release is deleted; the 14-day limit +applies only to Actions artifacts. The prerelease never becomes the latest release. + +A direct release asset URL avoids downloading/unzipping the whole artifact. +GitHub's native inline comment player requires a GitHub video attachment; +release asset links are not native comment attachments. The supported release +API works with the workflow token and requires no browser session credentials. +Native inline attachments are deferred: the user-token attempt was rejected by +GitHub. The current workflow does not reference `GHOSTWRIGHT_ATTACHMENT_TOKEN`. + +The separate report job checks out no code, downloads the current run's recording artifact as data, +publishes only `simple-cli.mp4` and `vim-normal-skills.mp4` (each limited to 25 MiB), and updates one bot +comment. It never executes downloaded files. Missing MP4s retain diagnostics; +publishing failures still update the comment and then fail the report job. Only same-repository PRs receive comments in this +prototype; forks and manual runs retain their Actions summaries/artifacts. The +report job verifies the PR head still matches the event before writing. It uses +pull-requests:write, actions:read and contents:write (for release uploads), while the build/test job only has contents:read. This is a +same-repository prototype, not the planned trusted-default-branch publisher for +arbitrary fork PRs. Tool versions and download checksums are pinned in the workflow where practical. -FFmpeg, ncurses and fonts come from Ubuntu 24.04's package repositories. Native +Vim, FFmpeg, ncurses and fonts come from Ubuntu 24.04's package repositories. Native artifacts are rebuilt from the pinned Ghostty source, patch and Rust lockfile; the build refreshes artifact hashes in the packaged manifest and verifies its ABI. No native build cache is used in this first prototype. @@ -65,7 +55,8 @@ lock (4.0.3) were incompatible even though the linked development graph worked. For a local run, build Ghostwright with `pnpm build` in its directory (after building native artifacts), `npm pack` into a temporary directory, copy -`ci/simple-cli/*` into an empty consumer directory, run `npm ci`, then +`ci/simple-cli/*` and `examples/vim-netrw/{journey.mjs,netrw.ts}` into an empty +consumer directory, run `npm ci`, then `npm install --no-save --package-lock=false /absolute/path/ghostwright-0.1.0.tgz`. -Set `GHOSTWRIGHT_RECORD_DIR` to a writable directory and run `node --test test.mjs` -from that consumer. agg and FFmpeg must be on PATH. Use Node 24.22.0 as in CI. +Set `GHOSTWRIGHT_RECORD_DIR` to a writable directory and run `node --test test.mjs vim.test.mjs` +from that consumer. Vim (with netrw), agg and FFmpeg must be on PATH. Use Node 24.22.0 as in CI. diff --git a/experiments/ghostwright/ci/simple-cli/vim.test.mjs b/experiments/ghostwright/ci/simple-cli/vim.test.mjs new file mode 100644 index 0000000..3abca83 --- /dev/null +++ b/experiments/ghostwright/ci/simple-cli/vim.test.mjs @@ -0,0 +1,4 @@ +import { test } from 'node:test'; +import { vimNormalSkills } from './journey.mjs'; + +test('built Ghostwright records netrw and Vim editing', { timeout: 250_000 }, vimNormalSkills); diff --git a/experiments/ghostwright/examples/vim-netrw/journey.mjs b/experiments/ghostwright/examples/vim-netrw/journey.mjs new file mode 100644 index 0000000..ea059dc --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/journey.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { withTerminal } from 'ghostwright'; +import { recordingExtension } from 'ghostwright/recording'; +import { netrw } from './netrw.ts'; + +/** Drive the same visible Vim editing journey in local and packaged CI tests. */ +export async function vimNormalSkills() { + const directory = await mkdtemp(`${tmpdir()}/vim-skills-`); + const output = process.env.GHOSTWRIGHT_RECORD_DIR ?? '.ghostwright/recordings'; + const recording = recordingExtension({ + output: `${output}/vim-normal-skills`, + formats: ['gif', 'mp4'], + stepMs: 850, + }); + try { + await writeFile( + `${directory}/refactor.ts`, + [ + '// NORMAL MODE: small motions, big edits', + 'const title = "draft";', + 'const label = "draft";', + 'const squad = "alpha";', + 'const score = 41;', + '', + '// ci" -> . -> gUiw -> yyp -> Ctrl-A -> u -> Ctrl-R', + '', + ].join('\n'), + ); + await writeFile( + `${directory}/WELCOME.txt`, + 'VIM NORMAL-MODE LAB\n\nOpen netrw with :Vexplore .\nChoose refactor.ts and make it shine.\n', + ); + await withTerminal( + { + command: process.env.GHOSTWRIGHT_VIM ?? 'vim', + args: [ + '-Nu', + 'NONE', + '-i', + 'NONE', + '-n', + '--cmd', + 'set nocompatible', + '--cmd', + 'set runtimepath=$VIMRUNTIME packpath=$VIMRUNTIME', + '-c', + 'let g:netrw_dirhistmax=0 | let g:netrw_liststyle=0 | let g:netrw_winsize=48', + '-c', + 'runtime plugin/netrwPlugin.vim', + '-c', + 'filetype on | syntax enable | set background=dark', + '-c', + 'set laststatus=2 noshowmode noruler mouse=a ttymouse=sgr', + 'WELCOME.txt', + ], + cwd: directory, + env: { HOME: directory, EXINIT: '', VIMINIT: '', LC_ALL: 'C' }, + viewport: { columns: 120, rows: 26 }, + extensions: [recording], + trace: 'retain-on-failure', + }, + async (ui) => { + using _capture = recording; + await ui.screen.findByText('VIM NORMAL-MODE LAB'); + await ui.keyboard.type(':Vexplore .'); + await ui.keyboard.press('Enter'); + const explorer = netrw(ui); + await ui.expect(explorer.region).toContainText('Netrw Directory Listing'); + const file = explorer.find('refactor.ts'); + await file.open(); + await ui.expect(file.editor).toContainText('const title = "draft";'); + // A text object replaces only the quoted content, keeping its delimiters. + await ui.keyboard.type('gg2j0'); + await ui.keyboard.type('ci"LIVE'); + await ui.keyboard.press('Escape'); + await ui.expect(file.editor).toContainText('const label = "LIVE";'); + // Repeat the previous change on the other quoted string. + await ui.keyboard.type('k0.'); + await ui.expect(file.editor).toContainText('const title = "LIVE";'); + // Operator + motion: uppercase the word under the cursor. + await ui.keyboard.type('2j0f"l'); + await ui.keyboard.type('gUiw'); + await ui.expect(file.editor).toContainText('const squad = "ALPHA";'); + // Yank and put a complete line, then change the copy with a text object. + await ui.keyboard.type('yyp'); + await ui.keyboard.type('0ciwlet'); + await ui.keyboard.press('Escape'); + await ui.expect(file.editor).toContainText('let squad = "ALPHA";'); + await ui.keyboard.type('j0f4'); + await ui.keyboard.press({ key: 'a', control: true }); + await ui.expect(file.editor).toContainText('const score = 42;'); + await ui.keyboard.type('u'); + await ui.expect(file.editor).toContainText('const score = 41;'); + await ui.keyboard.press({ key: 'r', control: true }); + await ui.expect(file.editor).toContainText('const score = 42;'); + await ui.keyboard.type(':w'); + await ui.keyboard.press('Enter'); + await ui.screen.findByText('written'); + recording.stop(); + await ui.keyboard.type(':qa!'); + await ui.keyboard.press('Enter'); + assert.equal((await ui.process.waitForExit()).exitCode, 0); + }, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/experiments/ghostwright/examples/vim-netrw/netrw.ts b/experiments/ghostwright/examples/vim-netrw/netrw.ts index 151b3b0..c99e297 100644 --- a/experiments/ghostwright/examples/vim-netrw/netrw.ts +++ b/experiments/ghostwright/examples/vim-netrw/netrw.ts @@ -7,7 +7,7 @@ import { type RegionInspection, type RegionLocator, type ScreenSnapshot, -} from '../../src/index.ts'; +} from 'ghostwright'; const heading = '" Netrw Directory Listing'; const bannerRule = /^" ={3,}\s*$/; diff --git a/experiments/ghostwright/examples/vim-netrw/normal-skills.vitest.ts b/experiments/ghostwright/examples/vim-netrw/normal-skills.vitest.ts index 8c03aef..5b68f72 100644 --- a/experiments/ghostwright/examples/vim-netrw/normal-skills.vitest.ts +++ b/experiments/ghostwright/examples/vim-netrw/normal-skills.vitest.ts @@ -1,109 +1,4 @@ -import { expect, test } from 'vitest'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { withTerminal } from '../../src/index.ts'; -import { recordingExtension } from '../../spikes/recording.ts'; -import { netrw } from './netrw.ts'; +import { test } from 'vitest'; +import { vimNormalSkills } from './journey.mjs'; -test('records netrw and Vim normal-mode editing skills', async () => { - const directory = await mkdtemp(`${tmpdir()}/vim-skills-`); - const output = process.env.GHOSTWRIGHT_RECORD_DIR ?? '.ghostwright/recordings'; - const recording = recordingExtension({ - output: `${output}/vim-normal-skills`, - formats: ['gif', 'mp4'], - stepMs: 850, - }); - try { - await writeFile( - `${directory}/refactor.ts`, - [ - '// NORMAL MODE: small motions, big edits', - 'const title = "draft";', - 'const label = "draft";', - 'const squad = "alpha";', - 'const score = 41;', - '', - '// ci" -> . -> gUiw -> yyp -> Ctrl-A -> u -> Ctrl-R', - '', - ].join('\n'), - ); - await writeFile( - `${directory}/WELCOME.txt`, - 'VIM NORMAL-MODE LAB\n\nOpen netrw with :Vexplore .\nChoose refactor.ts and make it shine.\n', - ); - await withTerminal( - { - command: process.env.GHOSTWRIGHT_VIM ?? 'vim', - args: [ - '-Nu', - 'NONE', - '-i', - 'NONE', - '-n', - '--cmd', - 'set nocompatible', - '--cmd', - 'set runtimepath=$VIMRUNTIME packpath=$VIMRUNTIME', - '-c', - 'let g:netrw_dirhistmax=0 | let g:netrw_liststyle=0 | let g:netrw_winsize=48', - '-c', - 'runtime plugin/netrwPlugin.vim', - '-c', - 'filetype on | syntax enable | set background=dark', - '-c', - 'set laststatus=2 noshowmode noruler mouse=a ttymouse=sgr', - 'WELCOME.txt', - ], - cwd: directory, - env: { HOME: directory, EXINIT: '', VIMINIT: '', LC_ALL: 'C' }, - viewport: { columns: 120, rows: 26 }, - extensions: [recording], - trace: 'retain-on-failure', - }, - async (ui) => { - using _capture = recording; - await ui.screen.findByText('VIM NORMAL-MODE LAB'); - await ui.keyboard.type(':Vexplore .'); - await ui.keyboard.press('Enter'); - const explorer = netrw(ui); - await ui.expect(explorer.region).toContainText('Netrw Directory Listing'); - const file = explorer.find('refactor.ts'); - await file.open(); - await ui.expect(file.editor).toContainText('const title = "draft";'); - // A text object replaces only the quoted content, keeping its delimiters. - await ui.keyboard.type('gg2j0'); - await ui.keyboard.type('ci"LIVE'); - await ui.keyboard.press('Escape'); - await ui.expect(file.editor).toContainText('const label = "LIVE";'); - // Repeat the previous change on the other quoted string. - await ui.keyboard.type('k0.'); - await ui.expect(file.editor).toContainText('const title = "LIVE";'); - // Operator + motion: uppercase the word under the cursor. - await ui.keyboard.type('2j0f"l'); - await ui.keyboard.type('gUiw'); - await ui.expect(file.editor).toContainText('const squad = "ALPHA";'); - // Yank and put a complete line, then change the copy with a text object. - await ui.keyboard.type('yyp'); - await ui.keyboard.type('0ciwlet'); - await ui.keyboard.press('Escape'); - await ui.expect(file.editor).toContainText('let squad = "ALPHA";'); - await ui.keyboard.type('j0f4'); - await ui.keyboard.press({ key: 'a', control: true }); - await ui.expect(file.editor).toContainText('const score = 42;'); - await ui.keyboard.type('u'); - await ui.expect(file.editor).toContainText('const score = 41;'); - await ui.keyboard.press({ key: 'r', control: true }); - await ui.expect(file.editor).toContainText('const score = 42;'); - await ui.keyboard.type(':w'); - await ui.keyboard.press('Enter'); - await ui.screen.findByText('written'); - recording.stop(); - await ui.keyboard.type(':qa!'); - await ui.keyboard.press('Enter'); - expect((await ui.process.waitForExit()).exitCode).toBe(0); - }, - ); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}, 250_000); +test('records netrw and Vim normal-mode editing skills', vimNormalSkills, 250_000);