diff --git a/.github/actions/record-simple-cli/action.yml b/.github/actions/record-simple-cli/action.yml new file mode 100644 index 0000000..6dfce28 --- /dev/null +++ b/.github/actions/record-simple-cli/action.yml @@ -0,0 +1,68 @@ +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 + 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/" + 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 + 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" "$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-cli-vim-${{ 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 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" + 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..3c574bc --- /dev/null +++ b/.github/workflows/ghostwright-recording.yml @@ -0,0 +1,198 @@ +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 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" + 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 + contents: write + actions: read + steps: + # 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-cli-vim-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/recording-media + - name: Publish direct MP4 links + 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 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 (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', + })); + } + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, + }); + 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); + } + - name: Update recording comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + 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 }} + 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 and Vim recordings', + '', + `${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 + ? `[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.` + : 'No recording was produced. See the build/test logs.', + '', + `[View Actions run](${run})`, + '', + '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)); + 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 new file mode 100644 index 0000000..fab0678 --- /dev/null +++ b/experiments/ghostwright/ci/README.md @@ -0,0 +1,62 @@ +# 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 +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. 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 +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. +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. + +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/*` 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 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/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..0379246 --- /dev/null +++ b/experiments/ghostwright/ci/simple-cli/package.json @@ -0,0 +1,8 @@ +{ + "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/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); 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.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/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/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"] } 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: {}