diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..3eba852 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-08-07T19:41:37.524Z for PR creation at branch issue-187-3d458fd12c95 for issue https://github.com/link-foundation/command-stream/issues/187 \ No newline at end of file diff --git a/js/.changeset/terminal-session-api.md b/js/.changeset/terminal-session-api.md new file mode 100644 index 0000000..f95ac9e --- /dev/null +++ b/js/.changeset/terminal-session-api.md @@ -0,0 +1,5 @@ +--- +'command-stream': minor +--- + +Add `openTerminal()`, an incremental PTY session alongside `captureTerminal()`: the child stays alive with no implicit timeout, `waitFor()` reuses the existing readiness matcher (including `idleMilliseconds`), `send()` accepts the `interactions` vocabulary at any later point, and `close()`/`dispose()` return the usual frames, transcript, and asciicast. `captureTerminal()` is now implemented on top of it. diff --git a/js/README.md b/js/README.md index 50cbd9a..b1aeee5 100644 --- a/js/README.md +++ b/js/README.md @@ -458,6 +458,62 @@ the idle wait. The complete, runnable [`tui-e2e.mjs`](examples/tui-e2e.mjs) example navigates a raw-mode menu and asserts on its captured output. +#### Interactive sessions + +`captureTerminal()` is a batch call: every interaction is known up front and the +child is killed after `timeoutMilliseconds` (30 s by default). When the input +arrives later and from elsewhere — an authorization code a human pastes back +minutes later, a chat-ops bridge, a test that interleaves assertions with input +— use `openTerminal()`, which keeps the same PTY open until you close it: + +```javascript +import { openTerminal } from 'command-stream'; + +const session = await openTerminal({ + file: 'my-cli', + args: ['login'], + cols: 80, +}); + +// Same readiness matcher as interactions, including idleMilliseconds. +await session.waitFor(/https:\/\/\S+/, { idleMilliseconds: 50 }); +const url = session.transcript.match(/https:\/\/\S+/)[0]; + +// ... arbitrary time passes; nothing terminates the child ... + +await session.send({ text: code, key: 'ENTER' }); +await session.waitFor('Logged in'); + +const capture = await session.close(); // frames/transcript/asciicast as usual +``` + +`openTerminal()` accepts every `captureTerminal()` option, including +`interactions` for the parts that _are_ known up front, but its +`timeoutMilliseconds` has no default: a session runs until the child exits or +you close it, and passing an explicit value opts back into a deadline. +`captureTerminal()` is implemented on top of `openTerminal()`, so the two paths +cannot drift. + +The session exposes: + +- `waitFor(pattern, { idleMilliseconds, timeoutMilliseconds })` — resolves with + the current transcript once `pattern` (string or RegExp) has been seen and, + with `idleMilliseconds`, output has been quiet for that long. It rejects if + the wait times out or the child exits first. +- `send(interaction | interaction[])` — the `text`, `key`, and `resize` + vocabulary of `interactions`; an entry may also carry `after` / + `idleMilliseconds` to wait before it is applied. +- `output`, `transcript`, `frames`, `asciicast`, `running`, `exitStatus`, + and `exited` (a promise for the child's exit) for live inspection. +- `close({ signal, timeoutMilliseconds })` — signals the child (escalating to + `SIGKILL`), then returns the same result object `captureTerminal()` resolves + to and writes `artifactDirectory` artifacts. `dispose()` is the + never-throwing variant for cleanup paths, and `finished()` waits for a child + that exits on its own. + +The runnable [`tui-session.mjs`](examples/tui-session.mjs) example walks through +a deferred-input login. + The artifact directory contains an unrolled `transcript.txt`, machine-readable `frames.json`, an asciicast v2 `session.cast`, a final `snapshot.svg`, a self-contained animated `recording.svg`, and `recording.gif`. Frames retain diff --git a/js/examples/tui-session.mjs b/js/examples/tui-session.mjs new file mode 100644 index 0000000..1191ba2 --- /dev/null +++ b/js/examples/tui-session.mjs @@ -0,0 +1,44 @@ +// Drive a TUI with input that only becomes available later: the session stays +// open between an authorization URL being printed and the code being typed. +import assert from 'node:assert/strict'; + +import { openTerminal } from 'command-stream'; + +const login = String.raw` +process.stdin.setRawMode(true); +let typed = ''; +process.stdin.on('data', (data) => { + for (const character of data.toString()) { + if (character === '\r') { + process.stdout.write('\r\nLogged in with ' + typed + '\r\n'); + setTimeout(() => process.exit(0), 20); + } else { + typed += character; + } + } +}); +process.stdout.write('Open https://example.test/device?code=ABCD-1234\r\n'); +`; + +const session = await openTerminal({ + file: process.execPath, + args: ['-e', login], + cols: 80, + rows: 10, +}); + +// Same readiness semantics as `interactions`, including idleMilliseconds. +await session.waitFor(/Open (https:\/\/\S+)/, { idleMilliseconds: 50 }); +const url = session.transcript.match(/Open (https:\/\/\S+)/)[1]; +console.log(`authorize at ${url}`); + +// A human opens the URL and comes back with a code; no timeout kills the child. +await new Promise((resolve) => setTimeout(resolve, 250)); + +await session.send({ text: 'ABCD-1234', key: 'ENTER' }); +await session.waitFor('Logged in with ABCD-1234'); + +const capture = await session.close(); +assert.equal(capture.exitCode, 0); +assert.match(capture.transcript, /Logged in with ABCD-1234/); +console.log(capture.transcript); diff --git a/js/src/$.mjs b/js/src/$.mjs index c028519..f726b0b 100755 --- a/js/src/$.mjs +++ b/js/src/$.mjs @@ -20,6 +20,7 @@ import { } from './$.ansi.mjs'; import { captureTerminal, + openTerminal, readAsciicast, unrollTerminalFrames, } from './terminal-capture.mjs'; @@ -464,6 +465,7 @@ export { processOutput, forceCleanupAll, captureTerminal, + openTerminal, readAsciicast, unrollTerminalFrames, }; diff --git a/js/src/terminal-capture.mjs b/js/src/terminal-capture.mjs index 2c59255..9fa4e7f 100644 --- a/js/src/terminal-capture.mjs +++ b/js/src/terminal-capture.mjs @@ -251,17 +251,20 @@ const createCaptureRecorder = (asciicast, onTrace) => { }; }; -const interactionAfter = (interaction, output) => { - if (interaction.after === undefined) { +const matchesOutput = (pattern, output) => { + if (pattern === undefined) { return true; } - if (interaction.after instanceof RegExp) { - interaction.after.lastIndex = 0; - return interaction.after.test(output); + if (pattern instanceof RegExp) { + pattern.lastIndex = 0; + return pattern.test(output); } - return output.includes(interaction.after); + return output.includes(pattern); }; +const interactionAfter = (interaction, output) => + matchesOutput(interaction.after, output); + const applyInteraction = ({ interaction, process, terminal, record }) => { if (interaction.text !== undefined) { const text = String(interaction.text); @@ -401,19 +404,301 @@ const persistArtifacts = async ({ } }; -const resolveTerminalRows = ({ cols, rows, aspectRatio }) => { +const resolveTerminalRows = ({ cols, rows, aspectRatio, label }) => { if (!(aspectRatio > 0)) { - throw new TypeError( - 'captureTerminal aspectRatio must be greater than zero' - ); + throw new TypeError(`${label} aspectRatio must be greater than zero`); } return rows ?? Math.max(1, Math.round(cols / (2 * aspectRatio))); }; +const hasTimeout = (timeoutMilliseconds) => + typeof timeoutMilliseconds === 'number' && + timeoutMilliseconds > 0 && + Number.isFinite(timeoutMilliseconds); + +const createWaiterRegistry = (output) => { + const waiters = new Set(); + let finished = false; + const release = (waiter) => { + waiters.delete(waiter); + clearTimeout(waiter.idleTimer); + clearTimeout(waiter.deadline); + }; + const check = () => { + for (const waiter of [...waiters]) { + if (!matchesOutput(waiter.pattern, output())) { + continue; + } + clearTimeout(waiter.idleTimer); + if (waiter.idleMilliseconds > 0 && !finished) { + waiter.idleTimer = setTimeout(() => { + release(waiter); + waiter.resolve(); + }, waiter.idleMilliseconds); + continue; + } + release(waiter); + waiter.resolve(); + } + }; + return { + check, + add: ({ pattern, idleMilliseconds = 0, timeoutMilliseconds }) => + new Promise((resolve, reject) => { + const waiter = { pattern, idleMilliseconds, resolve, reject }; + if (hasTimeout(timeoutMilliseconds)) { + waiter.deadline = setTimeout(() => { + release(waiter); + reject( + new Error( + `Terminal waitFor timed out after ${timeoutMilliseconds} ms` + ) + ); + }, timeoutMilliseconds); + } + waiters.add(waiter); + check(); + }), + finish: (reason) => { + finished = true; + check(); + for (const waiter of [...waiters]) { + release(waiter); + waiter.reject(reason()); + } + }, + }; +}; + +const watchTerminal = ({ + child, + state, + outputWriter, + settle, + appendFrame, + waiters, + interactionCoordinator, + record, + trace, + stopMarker, + stopMarkerGraceMilliseconds, + timeoutMilliseconds, +}) => { + let stopTimer, timeoutTimer; + let stopMarkerSeen = false; + return new Promise((resolve) => { + if (hasTimeout(timeoutMilliseconds)) { + timeoutTimer = setTimeout(() => { + state.error = new Error( + `Terminal command timed out after ${timeoutMilliseconds} ms` + ); + trace('timeout', { timeoutMilliseconds }); + child.kill('SIGTERM'); + }, timeoutMilliseconds); + } + + child.onData((data) => { + interactionCoordinator.outputArrived(); + trace('output', { data }); + state.output += data; + record('o', data); + outputWriter.write(data); + outputWriter.after(settle); + interactionCoordinator.advance(); + waiters.check(); + + if (stopMarker && state.output.includes(stopMarker) && !stopMarkerSeen) { + stopMarkerSeen = true; + outputWriter.after(appendFrame); + stopTimer = setTimeout( + () => child.kill('SIGTERM'), + stopMarkerGraceMilliseconds + ); + } + }); + child.onExit(({ exitCode, signal, error }) => { + trace('exit', { exitCode, signal }); + clearTimeout(timeoutTimer); + clearTimeout(stopTimer); + interactionCoordinator.close(); + state.error ??= error; + state.exitStatus = { exitCode, signal }; + waiters.finish( + () => + state.error ?? + new Error( + `Terminal exited with code ${exitCode} before the expected output arrived` + ) + ); + resolve(state.exitStatus); + }); + }); +}; + +const createTerminalSessionApi = ({ + child, + terminal, + state, + frames, + asciicast, + outputWriter, + waiters, + interactionCoordinator, + record, + elapsed, + appendFrame, + clearSettle, + markDisposed, + isDisposed, + completion, + artifactDirectory, + artifactOptions, +}) => { + let finalized; + + const drain = async () => { + outputWriter.flush(); + await outputWriter.settled(); + }; + + const finalize = () => { + finalized ??= (async () => { + const status = await completion; + try { + await drain(); + clearSettle(); + appendFrame(); + } finally { + markDisposed(); + terminal.dispose(); + } + + const transcript = unrollTerminalFrames(frames); + await persistArtifacts({ + artifactDirectory, + frames, + transcript, + asciicast, + artifactOptions, + }); + + return captureResult({ + status, + output: state.output, + transcript, + frames, + interactionCount: interactionCoordinator.count(), + asciicast, + }); + })(); + return finalized; + }; + + const finished = async () => { + const capture = await finalize(); + if (state.error) { + state.error.capture = capture; + throw state.error; + } + return capture; + }; + + const currentTranscript = () => { + if (isDisposed()) { + return unrollTerminalFrames(frames); + } + const frame = terminalFrame(terminal, elapsed); + const known = frames.at(-1); + return unrollTerminalFrames( + known && sameFrame(known, frame) ? frames : [...frames, frame] + ); + }; + + const waitFor = async (pattern, options = {}) => { + await waiters.add({ pattern, ...options }); + await drain(); + return currentTranscript(); + }; + + const send = async (input) => { + for (const interaction of Array.isArray(input) ? input : [input]) { + if (interaction.after !== undefined || interaction.idleMilliseconds) { + await waitFor(interaction.after, { + idleMilliseconds: interaction.idleMilliseconds, + timeoutMilliseconds: interaction.timeoutMilliseconds, + }); + } + if (state.exitStatus) { + throw new Error('Terminal session has already exited'); + } + await outputWriter.after(() => { + appendFrame(); + applyInteraction({ interaction, process: child, terminal, record }); + }); + } + return currentTranscript(); + }; + + const stop = async ({ + signal = 'SIGTERM', + timeoutMilliseconds: killTimeout = 5_000, + } = {}) => { + if (!state.exitStatus) { + child.kill(signal); + const stopped = await Promise.race([ + completion.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), killTimeout)), + ]); + if (!stopped) { + child.kill('SIGKILL'); + } + } + return finalize(); + }; + + return { + get output() { + return state.output; + }, + get transcript() { + return currentTranscript(); + }, + get frames() { + return frames; + }, + get asciicast() { + return asciicast; + }, + get exitStatus() { + return state.exitStatus; + }, + get running() { + return state.exitStatus === undefined; + }, + process: child, + terminal, + exited: completion, + waitFor, + send, + finished, + close: stop, + dispose: async (options) => { + try { + return await stop(options); + } catch { + return undefined; + } + }, + }; +}; + /** - * Run a command inside a real pseudoterminal and retain its settled TUI states. + * Open a pseudoterminal session that stays alive until the caller closes it. + * + * Unlike `captureTerminal`, input may be sent at any later point through + * `session.send()`, and readiness can be awaited with `session.waitFor()`. */ -export const captureTerminal = async ({ +export const openTerminal = async ({ file, args = [], cwd = process.cwd(), @@ -425,15 +710,16 @@ export const captureTerminal = async ({ interactions = [], stopMarker, stopMarkerGraceMilliseconds = 250, - timeoutMilliseconds = 30_000, + timeoutMilliseconds, artifactDirectory, artifactOptions, onTrace, + label = 'openTerminal', } = {}) => { if (!file) { - throw new TypeError('captureTerminal requires a file'); + throw new TypeError(`${label} requires a file`); } - const terminalRows = resolveTerminalRows({ cols, rows, aspectRatio }); + const terminalRows = resolveTerminalRows({ cols, rows, aspectRatio, label }); const { child, environment, terminal } = await startTerminal({ file, @@ -455,11 +741,13 @@ export const captureTerminal = async ({ trace: traceCapture, } = createCaptureRecorder(asciicast, onTrace); const frames = []; - let output = ''; - let settleTimer, stopTimer; - let stopMarkerSeen = false; - let captureError; + const state = { output: '', error: undefined, exitStatus: undefined }; + let settleTimer; + let disposed = false; const appendFrame = () => { + if (disposed) { + return; + } const frame = terminalFrame(terminal, elapsed); if (!frames.at(-1) || !sameFrame(frames.at(-1), frame)) { frames.push(frame); @@ -470,9 +758,10 @@ export const captureTerminal = async ({ settleTimer = setTimeout(appendFrame, settleMilliseconds); }; const outputWriter = createTerminalOutputWriter(terminal, appendFrame); + const waiters = createWaiterRegistry(() => state.output); const interactionCoordinator = createInteractionCoordinator({ interactions, - output: () => output, + output: () => state.output, outputWriter, appendFrame, trace: traceCapture, @@ -481,76 +770,54 @@ export const captureTerminal = async ({ record, }); - const completion = new Promise((resolve) => { - const timeout = setTimeout(() => { - captureError = new Error( - `Terminal command timed out after ${timeoutMilliseconds} ms` - ); - traceCapture('timeout', { timeoutMilliseconds }); - child.kill('SIGTERM'); - }, timeoutMilliseconds); - - child.onData((data) => { - interactionCoordinator.outputArrived(); - traceCapture('output', { data }); - output += data; - record('o', data); - outputWriter.write(data); - outputWriter.after(settle); - interactionCoordinator.advance(); - - if (stopMarker && output.includes(stopMarker) && !stopMarkerSeen) { - stopMarkerSeen = true; - outputWriter.after(appendFrame); - stopTimer = setTimeout( - () => child.kill('SIGTERM'), - stopMarkerGraceMilliseconds - ); - } - }); - child.onExit(({ exitCode, signal, error }) => { - traceCapture('exit', { exitCode, signal }); - clearTimeout(timeout); - clearTimeout(stopTimer); - interactionCoordinator.close(); - captureError ??= error; - resolve({ exitCode, signal }); - }); + const completion = watchTerminal({ + child, + state, + outputWriter, + settle, + appendFrame, + waiters, + interactionCoordinator, + record, + trace: traceCapture, + stopMarker, + stopMarkerGraceMilliseconds, + timeoutMilliseconds, }); - let status; - try { - status = await completion; - outputWriter.flush(); - await outputWriter.settled(); - clearTimeout(settleTimer); - appendFrame(); - } finally { - terminal.dispose(); - } - - const transcript = unrollTerminalFrames(frames); - await persistArtifacts({ - artifactDirectory, + return createTerminalSessionApi({ + child, + terminal, + state, frames, - transcript, asciicast, + outputWriter, + waiters, + interactionCoordinator, + record, + elapsed, + appendFrame, + clearSettle: () => clearTimeout(settleTimer), + markDisposed: () => { + disposed = true; + }, + isDisposed: () => disposed, + completion, + artifactDirectory, artifactOptions, }); +}; - const capture = captureResult({ - status, - output, - transcript, - frames, - interactionCount: interactionCoordinator.count(), - asciicast, +/** + * Run a command inside a real pseudoterminal and retain its settled TUI states. + */ +export const captureTerminal = async (options = {}) => { + const session = await openTerminal({ + ...options, + timeoutMilliseconds: options.timeoutMilliseconds ?? 30_000, + label: 'captureTerminal', }); - if (captureError) { - captureError.capture = capture; - throw captureError; - } - return capture; + return session.finished(); }; export { readAsciicast, unrollTerminalFrames }; diff --git a/js/tests/fixtures/tui-session-fixture.mjs b/js/tests/fixtures/tui-session-fixture.mjs new file mode 100644 index 0000000..0b8b279 --- /dev/null +++ b/js/tests/fixtures/tui-session-fixture.mjs @@ -0,0 +1,18 @@ +process.stdin.setRawMode?.(true); +process.stdin.resume(); + +let typed = ''; +process.stdin.on('data', (chunk) => { + for (const character of chunk.toString()) { + if (character === '\r') { + process.stdout.write(`\r\nlogged-in:${typed}\r\n`); + setTimeout(() => process.exit(0), 20); + typed = ''; + } else { + typed += character; + } + } +}); + +process.stdout.write('auth-url: https://example.test/device?code=42\r\n'); +setTimeout(() => process.stdout.write('waiting for code\r\n'), 20); diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index b9c6fca..b1f8aee 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url'; import { captureTerminal, + openTerminal, readAsciicast, unrollTerminalFrames, } from '../src/$.mjs'; import { stopTerminal } from '../src/terminal-pty-host-platform.mjs'; +import { isWindows } from './test-helper.mjs'; import { spawnTerminalPty } from '../src/terminal-pty.mjs'; const directory = dirname(fileURLToPath(import.meta.url)); @@ -111,7 +113,9 @@ describe('PTY terminal capture', () => { .slice(0, 6) .toString() ).toBe('GIF89a'); - }); + // Rendering every artifact (including the GIF) runs close to the default + // 10 s budget on slow Windows runners. + }, 60_000); test('preserves styled cells, exact grid geometry, and real timing', async () => { const artifactDirectory = await mkdtemp( @@ -304,3 +308,116 @@ describe('PTY terminal capture', () => { ).toBeGreaterThan(0); }); }); + +describe('PTY terminal sessions', () => { + test('keeps the child alive for input that arrives much later', async () => { + const artifactDirectory = await mkdtemp( + join(tmpdir(), 'command-stream-tui-session-') + ); + temporaryDirectories.push(artifactDirectory); + + const session = await openTerminal({ + file: process.execPath, + args: [join(directory, 'fixtures/tui-session-fixture.mjs')], + cols: 60, + rows: 6, + settleMilliseconds: 10, + artifactDirectory, + }); + + await session.waitFor(/auth-url: (\S+)/, { idleMilliseconds: 30 }); + const url = session.transcript.match(/auth-url: (\S+)/)?.[1]; + expect(url).toBe('https://example.test/device?code=42'); + + // The caller leaves and comes back: nothing may terminate the child. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(session.running).toBe(true); + + await session.send({ text: '42', key: 'ENTER' }); + await session.waitFor('logged-in:42'); + + // The fixture exits on its own; wait for that instead of racing close() + // against it, because a kill during exit reports a signal exit code. + await session.exited; + const capture = await session.close(); + expect(capture.exitCode).toBe(0); + expect(capture.transcript).toContain('logged-in:42'); + expect(capture.frames.length).toBeGreaterThan(0); + expect(capture.asciicast.events.some(({ code }) => code === 'i')).toBe( + true + ); + expect( + await readFile(join(artifactDirectory, 'transcript.txt'), 'utf8') + ).toContain('logged-in:42'); + expect(session.running).toBe(false); + }); + + test('waitFor requires output quiescence and honours its own timeout', async () => { + const session = await openTerminal({ + file: process.execPath, + args: [join(directory, 'fixtures/tui-session-fixture.mjs')], + cols: 60, + rows: 6, + settleMilliseconds: 10, + }); + + const startedAt = Date.now(); + await session.waitFor('auth-url', { idleMilliseconds: 120 }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(120); + expect(session.output).toContain('waiting for code'); + + await expect( + session.waitFor('never-printed', { timeoutMilliseconds: 100 }) + ).rejects.toThrow('Terminal waitFor timed out after 100 ms'); + + await session.close(); + }); + + test('rejects pending waits and later sends once the child exits', async () => { + const session = await openTerminal({ + file: process.execPath, + args: ['-e', "process.stdout.write('bye'); process.exit(3)"], + cols: 40, + rows: 4, + }); + + // Windows reports the teardown through the PTY host rather than a child + // exit status, and ConPTY drops the output of a process this short-lived, + // so only the rejection itself is portable. + await expect(session.waitFor('never-printed')).rejects.toThrow( + isWindows ? /exited/ : 'Terminal exited with code 3' + ); + await expect(session.send({ text: 'late' })).rejects.toThrow( + 'already exited' + ); + + const capture = await session.close(); + if (!isWindows) { + expect(capture.exitCode).toBe(3); + expect(capture.transcript).toContain('bye'); + } + }); + + test('dispose stops a child that never exits on its own', async () => { + const session = await openTerminal({ + file: process.execPath, + args: [join(directory, 'fixtures/tui-hang-fixture.mjs')], + cols: 40, + rows: 4, + }); + + await session.waitFor('waiting for input'); + const capture = await session.dispose(); + expect(session.running).toBe(false); + expect(capture.transcript).toContain('waiting for input'); + }); + + test('requires a file just like the batch capture', async () => { + await expect(openTerminal({})).rejects.toThrow( + 'openTerminal requires a file' + ); + await expect(captureTerminal({})).rejects.toThrow( + 'captureTerminal requires a file' + ); + }); +}); diff --git a/rust/README.md b/rust/README.md index 0dea5d5..0e74f86 100644 --- a/rust/README.md +++ b/rust/README.md @@ -123,6 +123,56 @@ artifact directory receives `transcript.txt`, `frames.json`, `session.cast`, partial capture and those diagnostic files. Use `capture_terminal_async` from an async application. +### Interactive sessions + +`capture_terminal` is batch-only: every interaction is known up front and +`timeout` (30 s by default) kills the child. When the input arrives later and +from elsewhere — an authorization code a human pastes back minutes later, a +chat-ops bridge, a test that interleaves assertions with input — use +`open_terminal`, which keeps the same PTY open until you close it: + +```rust,no_run +use command_stream::terminal::{ + open_terminal, TerminalCaptureOptions, TerminalInteraction, TerminalKey, TerminalPattern, +}; +use std::time::Duration; + +let mut session = open_terminal(TerminalCaptureOptions { + file: "my-cli".into(), + args: vec!["login".into()], + ..TerminalCaptureOptions::default() +})?; + +session.wait_for( + &TerminalPattern::regex(r"https://\S+")?, + Duration::from_millis(50), + None, +)?; +let url = session.transcript(); + +// ... arbitrary time passes; nothing terminates the child ... + +session.send(&TerminalInteraction { + text: Some("ABCD-1234".into()), + key: Some(TerminalKey::Enter), + ..TerminalInteraction::default() +})?; +session.wait_for(&TerminalPattern::text("Logged in"), Duration::ZERO, None)?; + +let capture = session.close()?; +# let _ = (url, capture); +# Ok::<(), command_stream::terminal::TerminalCaptureError>(()) +``` + +`open_terminal` accepts every `capture_terminal` option and forces +`timeout: None`, so a session runs until the child exits or `close()` is called. +`wait_for` reuses the same readiness semantics as `interactions`, including the +idle wait, and fails when the child exits first or its own timeout elapses. +`send` uses the `TerminalInteraction` vocabulary, `close` stops the child and +returns the usual capture (writing `artifact_directory` artifacts), and `finish` +waits for a child that exits on its own. `capture_terminal` is implemented on +top of the same session, so the two paths cannot drift. + Interactions can wait for literal output with `after`, regex output with `after_regex`, and output quiescence with `idle_duration`. Named `TerminalKey` variants cover arrows, Enter, Tab, Escape, Backspace, Ctrl-C, and diff --git a/rust/changelog.d/20260807_200000_terminal_session_api.md b/rust/changelog.d/20260807_200000_terminal_session_api.md new file mode 100644 index 0000000..09b60c3 --- /dev/null +++ b/rust/changelog.d/20260807_200000_terminal_session_api.md @@ -0,0 +1,9 @@ +--- +bump: minor +--- + +### Added +- `open_terminal` returns a `TerminalSession` that keeps a PTY open with no implicit timeout, so input that only becomes available later can be sent with `send`, awaited with `wait_for` (the same readiness matcher as `interactions`, including the idle wait), and finalized with `close`/`finish`. + +### Changed +- `TerminalCaptureOptions::timeout` is now `Option` (`Some(30s)` by default, `None` for sessions), and `capture_terminal` is implemented on top of `TerminalSession`. diff --git a/rust/src/terminal/capture.rs b/rust/src/terminal/capture.rs index 299cfba..ff7808b 100644 --- a/rust/src/terminal/capture.rs +++ b/rust/src/terminal/capture.rs @@ -3,7 +3,7 @@ use super::types::{ Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError, TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalResize, }; -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, MasterPty, PtySize}; use regex::Regex; use std::collections::HashMap; use std::io::{Read, Write}; @@ -212,207 +212,446 @@ fn capture_result( } } -pub fn capture_terminal( - options: TerminalCaptureOptions, -) -> Result { - if options.file.is_empty() { - return Err(TerminalCaptureError::new( - "capture_terminal requires a file", - None, - )); +/// Readiness condition for [`TerminalSession::wait_for`], mirroring the +/// `after` / `after_regex` vocabulary of [`TerminalInteraction`]. +#[derive(Debug, Clone)] +pub enum TerminalPattern { + Text(String), + Regex(Regex), +} + +impl TerminalPattern { + pub fn text(value: impl Into) -> Self { + Self::Text(value.into()) } - let interaction_regexes = options - .interactions - .iter() - .map(|interaction| { - interaction - .after_regex - .as_ref() - .map(|pattern| { - Regex::new(pattern).map_err(|error| { - TerminalCaptureError::new( - format!("invalid terminal interaction regex: {error}"), - None, - ) - }) - }) - .transpose() + + pub fn regex(pattern: &str) -> Result { + Regex::new(pattern).map(Self::Regex).map_err(|error| { + TerminalCaptureError::new(format!("invalid terminal pattern regex: {error}"), None) }) - .collect::, _>>()?; - let pty = native_pty_system() - .openpty(PtySize { - rows: options.rows, - cols: options.cols, - pixel_width: 0, - pixel_height: 0, + } + + fn matches(&self, output: &str) -> bool { + match self { + Self::Text(value) => output.contains(value), + Self::Regex(pattern) => pattern.is_match(output), + } + } +} + +/// A pseudoterminal that stays open until the caller closes it, so input may be +/// sent long after the process started. +pub struct TerminalSession { + options: TerminalCaptureOptions, + interaction_regexes: Vec>, + master: Box, + writer: Box, + child: Box, + receiver: mpsc::Receiver>, + started: Instant, + parser: vt100::Parser, + recording: Asciicast, + output: String, + frames: Vec, + pending_render: Vec, + terminal_has_output: bool, + interaction_index: usize, + last_output: Option, + dirty: bool, + reader_closed: bool, + status: Option, + timed_out: bool, + stop_deadline: Option, +} + +impl TerminalSession { + fn open(options: TerminalCaptureOptions) -> Result { + if options.file.is_empty() { + return Err(TerminalCaptureError::new( + "open_terminal requires a file", + None, + )); + } + let interaction_regexes = options + .interactions + .iter() + .map(|interaction| { + interaction + .after_regex + .as_ref() + .map(|pattern| { + Regex::new(pattern).map_err(|error| { + TerminalCaptureError::new( + format!("invalid terminal interaction regex: {error}"), + None, + ) + }) + }) + .transpose() + }) + .collect::, _>>()?; + let pty = native_pty_system() + .openpty(PtySize { + rows: options.rows, + cols: options.cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; + let mut command = CommandBuilder::new(&options.file); + command.args(&options.args); + if let Some(cwd) = &options.cwd { + command.cwd(cwd); + } + command.env( + "TERM", + options + .env + .get("TERM") + .map_or("xterm-256color", String::as_str), + ); + for (name, value) in &options.env { + command.env(name, value); + } + let child = pty + .slave + .spawn_command(command) + .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; + drop(pty.slave); + let reader = pty + .master + .try_clone_reader() + .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; + let writer = pty + .master + .take_writer() + .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; + let receiver = spawn_reader(reader); + let recording = asciicast(&options); + let parser = vt100::Parser::new(options.rows, options.cols, 100_000); + Ok(Self { + interaction_regexes, + master: pty.master, + writer, + child, + receiver, + started: Instant::now(), + parser, + recording, + output: String::new(), + frames: Vec::new(), + pending_render: Vec::new(), + terminal_has_output: false, + interaction_index: 0, + last_output: None, + dirty: false, + reader_closed: false, + status: None, + timed_out: false, + stop_deadline: None, + options, }) - .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; - let mut command = CommandBuilder::new(&options.file); - command.args(&options.args); - if let Some(cwd) = &options.cwd { - command.cwd(cwd); } - command.env( - "TERM", - options - .env - .get("TERM") - .map_or("xterm-256color", String::as_str), - ); - for (name, value) in &options.env { - command.env(name, value); + + /// Raw PTY output seen so far. + pub fn output(&self) -> &str { + &self.output } - let mut child = pty - .slave - .spawn_command(command) - .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; - drop(pty.slave); - let reader = pty - .master - .try_clone_reader() - .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; - let mut writer = pty - .master - .take_writer() - .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; - let receiver = spawn_reader(reader); - let started = Instant::now(); - let mut parser = vt100::Parser::new(options.rows, options.cols, 100_000); - let mut recording = asciicast(&options); - let mut output = String::new(); - let mut frames = Vec::new(); - let mut pending_render = Vec::new(); - let mut terminal_has_output = false; - let mut interaction_index = 0; - let mut last_output = None; - let mut dirty = false; - let mut reader_closed = false; - let mut status = None; - let mut timed_out = false; - let mut stop_deadline = None; - - loop { - match receiver.recv_timeout(Duration::from_millis(5)) { + + /// Settled states retained so far. + pub fn frames(&self) -> &[TerminalFrame] { + &self.frames + } + + /// Unrolled transcript of the states retained so far. + pub fn transcript(&self) -> String { + unroll_terminal_frames(&self.frames) + } + + /// Whether the child is still alive. + pub fn running(&self) -> bool { + self.status.is_none() + } + + fn read_available(&mut self) { + match self.receiver.recv_timeout(Duration::from_millis(5)) { Ok(data) => { let text = String::from_utf8_lossy(&data); - output.push_str(&text); - record(&mut recording, started, "o", text.into_owned()); - pending_render.extend_from_slice(&data); - let render_data = drain_complete_render_data(&mut pending_render); + self.output.push_str(&text); + record(&mut self.recording, self.started, "o", text.into_owned()); + self.pending_render.extend_from_slice(&data); + let render_data = drain_complete_render_data(&mut self.pending_render); let segments = render_segments(&render_data); let segment_count = segments.len(); - if terminal_has_output && render_data.starts_with(ERASE_SCREEN) { - append_frame(&mut frames, &parser, started); + if self.terminal_has_output && render_data.starts_with(ERASE_SCREEN) { + append_frame(&mut self.frames, &self.parser, self.started); } for (index, segment) in segments.into_iter().enumerate() { - parser.process(segment); - terminal_has_output |= !segment.is_empty(); + self.parser.process(segment); + self.terminal_has_output |= !segment.is_empty(); if index + 1 < segment_count { - append_frame(&mut frames, &parser, started); + append_frame(&mut self.frames, &self.parser, self.started); } } - last_output = Some(Instant::now()); - dirty = true; - if options + self.last_output = Some(Instant::now()); + self.dirty = true; + if self + .options .stop_marker .as_ref() - .is_some_and(|marker| output.contains(marker)) - && stop_deadline.is_none() + .is_some_and(|marker| self.output.contains(marker)) + && self.stop_deadline.is_none() { - append_frame(&mut frames, &parser, started); - stop_deadline = Some(Instant::now() + options.stop_marker_grace); + append_frame(&mut self.frames, &self.parser, self.started); + self.stop_deadline = Some(Instant::now() + self.options.stop_marker_grace); } } - Err(mpsc::RecvTimeoutError::Disconnected) => reader_closed = true, + Err(mpsc::RecvTimeoutError::Disconnected) => self.reader_closed = true, Err(mpsc::RecvTimeoutError::Timeout) => {} } + } + + fn idle_for(&self) -> Duration { + self.last_output + .map_or_else(|| self.started.elapsed(), |instant| instant.elapsed()) + } - while let Some(interaction) = options.interactions.get(interaction_index) { + fn apply_scripted_interactions(&mut self) -> Result<(), TerminalCaptureError> { + while let Some(interaction) = self.options.interactions.get(self.interaction_index) { if interaction .after .as_ref() - .is_some_and(|marker| !output.contains(marker)) + .is_some_and(|marker| !self.output.contains(marker)) { break; } - if interaction_regexes[interaction_index] + if self.interaction_regexes[self.interaction_index] .as_ref() - .is_some_and(|pattern| !pattern.is_match(&output)) + .is_some_and(|pattern| !pattern.is_match(&self.output)) { break; } if interaction.idle_duration > Duration::ZERO - && last_output.map_or_else(|| started.elapsed(), |instant| instant.elapsed()) - < interaction.idle_duration + && self.idle_for() < interaction.idle_duration { break; } - append_frame(&mut frames, &parser, started); + let interaction = interaction.clone(); + append_frame(&mut self.frames, &self.parser, self.started); apply_interaction( - interaction, - writer.as_mut(), - pty.master.as_ref(), - &mut parser, - &mut recording, - started, + &interaction, + self.writer.as_mut(), + self.master.as_ref(), + &mut self.parser, + &mut self.recording, + self.started, )?; - interaction_index += 1; + self.interaction_index += 1; } + Ok(()) + } + + /// Advance the capture by one step: read pending output, apply any scripted + /// interaction whose readiness condition became true, retain settled states, + /// and enforce the optional deadline. + fn poll(&mut self) -> Result<(), TerminalCaptureError> { + self.read_available(); + self.apply_scripted_interactions()?; - if dirty && last_output.is_some_and(|instant| instant.elapsed() >= options.settle_duration) + if self.dirty + && self + .last_output + .is_some_and(|instant| instant.elapsed() >= self.options.settle_duration) { - append_frame(&mut frames, &parser, started); - dirty = false; + append_frame(&mut self.frames, &self.parser, self.started); + self.dirty = false; } - if status.is_none() { - status = child + if self.status.is_none() { + self.status = self + .child .try_wait() .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?; } - if status.is_some() && reader_closed { - break; + if self.status.is_none() { + let expired = self + .options + .timeout + .is_some_and(|timeout| self.started.elapsed() >= timeout); + let stopped = self + .stop_deadline + .is_some_and(|deadline| Instant::now() >= deadline); + if expired || stopped { + self.timed_out = expired; + self.stop()?; + } } - if status.is_none() - && (started.elapsed() >= options.timeout - || stop_deadline.is_some_and(|deadline| Instant::now() >= deadline)) - { - timed_out = started.elapsed() >= options.timeout; - let _ = child.kill(); - status = Some( - child - .wait() - .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?, - ); + Ok(()) + } + + fn finished(&self) -> bool { + self.status.is_some() && self.reader_closed + } + + fn stop(&mut self) -> Result<(), TerminalCaptureError> { + let _ = self.child.kill(); + self.status = Some( + self.child + .wait() + .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?, + ); + Ok(()) + } + + /// Block until `pattern` has been seen and, when `idle` is non-zero, no + /// further output arrived for that long. New output restarts the idle wait. + pub fn wait_for( + &mut self, + pattern: &TerminalPattern, + idle: Duration, + timeout: Option, + ) -> Result<(), TerminalCaptureError> { + let deadline = timeout.map(|limit| Instant::now() + limit); + loop { + if pattern.matches(&self.output) && self.idle_for() >= idle { + return Ok(()); + } + if self.status.is_some() { + self.poll()?; + if pattern.matches(&self.output) { + return Ok(()); + } + if self.finished() { + return Err(TerminalCaptureError::new( + "terminal exited before the expected output arrived", + None, + )); + } + continue; + } + if deadline.is_some_and(|limit| Instant::now() >= limit) { + return Err(TerminalCaptureError::new( + format!( + "terminal wait_for timed out after {} ms", + timeout.unwrap_or_default().as_millis() + ), + None, + )); + } + self.poll()?; } } - parser.process(&pending_render); - append_frame(&mut frames, &parser, started); - let capture = capture_result( - status.expect("child status is available after capture loop"), - output, - frames, - interaction_index, - recording, - ); - if let Some(directory) = &options.artifact_directory { - write_terminal_artifacts( - directory, - &capture.frames, - &capture.transcript, - &capture.asciicast, - )?; + /// Send text, a named key, or a resize to the live terminal, using the same + /// vocabulary as [`TerminalInteraction`]. + pub fn send(&mut self, interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> { + if let Some(marker) = &interaction.after { + let pattern = TerminalPattern::text(marker.clone()); + self.wait_for(&pattern, interaction.idle_duration, None)?; + } else if let Some(expression) = &interaction.after_regex { + let pattern = TerminalPattern::regex(expression)?; + self.wait_for(&pattern, interaction.idle_duration, None)?; + } else if interaction.idle_duration > Duration::ZERO { + while self.idle_for() < interaction.idle_duration && self.status.is_none() { + self.poll()?; + } + } + if self.status.is_some() { + return Err(TerminalCaptureError::new( + "terminal session has already exited", + None, + )); + } + append_frame(&mut self.frames, &self.parser, self.started); + apply_interaction( + interaction, + self.writer.as_mut(), + self.master.as_ref(), + &mut self.parser, + &mut self.recording, + self.started, + ) + } + + /// Wait for a child that exits on its own, then produce the capture. + pub fn finish(mut self) -> Result { + while !self.finished() { + self.poll()?; + } + self.into_capture() } - if timed_out { + + /// Stop the child if it is still running, then produce the capture and write + /// any configured artifacts. + pub fn close(mut self) -> Result { + if self.status.is_none() { + self.stop()?; + } + while !self.finished() { + self.read_available(); + } + self.into_capture() + } + + fn into_capture(mut self) -> Result { + let pending = std::mem::take(&mut self.pending_render); + self.parser.process(&pending); + append_frame(&mut self.frames, &self.parser, self.started); + let capture = capture_result( + self.status + .clone() + .expect("child status is available after the capture loop"), + std::mem::take(&mut self.output), + std::mem::take(&mut self.frames), + self.interaction_index, + std::mem::replace(&mut self.recording, asciicast(&self.options)), + ); + if let Some(directory) = &self.options.artifact_directory { + write_terminal_artifacts( + directory, + &capture.frames, + &capture.transcript, + &capture.asciicast, + )?; + } + if self.timed_out { + return Err(TerminalCaptureError::new( + format!( + "terminal command timed out after {} ms", + self.options.timeout.unwrap_or_default().as_millis() + ), + Some(capture), + )); + } + Ok(capture) + } +} + +/// Open a terminal session that stays alive until the caller closes it. +/// +/// Unlike [`capture_terminal`], input may be sent at any later point through +/// [`TerminalSession::send`], and readiness can be awaited with +/// [`TerminalSession::wait_for`]. `options.timeout` defaults to `None` here, so +/// nothing terminates the child until [`TerminalSession::close`] is called. +pub fn open_terminal( + options: TerminalCaptureOptions, +) -> Result { + TerminalSession::open(TerminalCaptureOptions { + timeout: None, + ..options + }) +} + +/// Run a command inside a real pseudoterminal and retain its settled TUI states. +pub fn capture_terminal( + options: TerminalCaptureOptions, +) -> Result { + if options.file.is_empty() { return Err(TerminalCaptureError::new( - format!( - "terminal command timed out after {} ms", - options.timeout.as_millis() - ), - Some(capture), + "capture_terminal requires a file", + None, )); } - Ok(capture) + TerminalSession::open(options)?.finish() } pub async fn capture_terminal_async( diff --git a/rust/src/terminal/mod.rs b/rust/src/terminal/mod.rs index cda237e..9ed3679 100644 --- a/rust/src/terminal/mod.rs +++ b/rust/src/terminal/mod.rs @@ -3,7 +3,9 @@ mod capture; mod types; pub use artifacts::{read_asciicast, serialize_asciicast, unroll_terminal_frames}; -pub use capture::{capture_terminal, capture_terminal_async}; +pub use capture::{ + capture_terminal, capture_terminal_async, open_terminal, TerminalPattern, TerminalSession, +}; pub use types::{ Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError, TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalKey, diff --git a/rust/src/terminal/types.rs b/rust/src/terminal/types.rs index 02e8043..e0f0049 100644 --- a/rust/src/terminal/types.rs +++ b/rust/src/terminal/types.rs @@ -65,7 +65,9 @@ pub struct TerminalCaptureOptions { pub interactions: Vec, pub stop_marker: Option, pub stop_marker_grace: Duration, - pub timeout: Duration, + /// Deadline for the whole run. `None` keeps the child alive indefinitely, + /// which is the default for [`crate::terminal::open_terminal`] sessions. + pub timeout: Option, pub artifact_directory: Option, } @@ -82,7 +84,7 @@ impl Default for TerminalCaptureOptions { interactions: Vec::new(), stop_marker: None, stop_marker_grace: Duration::from_millis(250), - timeout: Duration::from_secs(30), + timeout: Some(Duration::from_secs(30)), artifact_directory: None, } } diff --git a/rust/tests/terminal_capture.rs b/rust/tests/terminal_capture.rs index cdc2961..653e2c1 100644 --- a/rust/tests/terminal_capture.rs +++ b/rust/tests/terminal_capture.rs @@ -1,8 +1,8 @@ #![cfg(unix)] use command_stream::terminal::{ - capture_terminal, read_asciicast, TerminalCaptureOptions, TerminalInteraction, TerminalKey, - TerminalResize, + capture_terminal, open_terminal, read_asciicast, TerminalCaptureOptions, TerminalInteraction, + TerminalKey, TerminalPattern, TerminalResize, }; use std::fs; use std::time::Duration; @@ -15,7 +15,7 @@ fn shell_options(script: &str) -> TerminalCaptureOptions { cols: 24, rows: 6, settle_duration: Duration::from_millis(10), - timeout: Duration::from_secs(3), + timeout: Some(Duration::from_secs(3)), ..TerminalCaptureOptions::default() } } @@ -136,7 +136,7 @@ fn retains_lines_after_they_scroll_off_the_visible_terminal() { fn writes_replay_artifacts_and_preserves_partial_capture_on_timeout() { let artifacts = tempdir().expect("artifact directory"); let mut options = shell_options("printf 'waiting for input'; sleep 5"); - options.timeout = Duration::from_millis(100); + options.timeout = Some(Duration::from_millis(100)); options.artifact_directory = Some(artifacts.path().into()); let error = capture_terminal(options).expect_err("capture should time out"); @@ -158,3 +158,102 @@ fn writes_replay_artifacts_and_preserves_partial_capture_on_timeout() { assert_eq!(cast.header.version, 2); assert!(cast.events.iter().any(|event| event.code == "o")); } + +#[test] +fn keeps_a_session_open_for_input_that_arrives_later() { + let script = r#" +printf 'auth-url: https://example.test/device?code=42\n' +IFS= read -r code +printf 'logged-in:%s\n' "$code" +"#; + let mut session = open_terminal(shell_options(script)).expect("session opens"); + session + .wait_for( + &TerminalPattern::regex(r"auth-url: (\S+)").expect("valid regex"), + Duration::from_millis(30), + None, + ) + .expect("auth url arrives"); + assert!(session + .output() + .contains("https://example.test/device?code=42")); + + // The code only becomes available much later; nothing may kill the child. + std::thread::sleep(Duration::from_millis(400)); + assert!(session.running()); + + session + .send(&TerminalInteraction { + text: Some("42".into()), + key: Some(TerminalKey::Enter), + ..TerminalInteraction::default() + }) + .expect("code is typed"); + session + .wait_for( + &TerminalPattern::text("logged-in:42"), + Duration::ZERO, + Some(Duration::from_secs(5)), + ) + .expect("login completes"); + + let capture = session.close().expect("session closes"); + assert_eq!(capture.exit_code, 0); + assert!(capture.transcript.contains("logged-in:42")); + assert!(capture + .asciicast + .events + .iter() + .any(|event| event.code == "i")); +} + +#[test] +fn reports_wait_for_timeouts_and_sends_after_exit() { + let mut session = open_terminal(shell_options("printf 'bye\\n'")).expect("session opens"); + let failure = session + .wait_for( + &TerminalPattern::text("never-printed"), + Duration::ZERO, + Some(Duration::from_millis(100)), + ) + .expect_err("wait_for gives up"); + assert!( + failure.to_string().contains("timed out") + || failure.to_string().contains("terminal exited"), + "unexpected error: {failure}" + ); + + let capture = session.close().expect("session closes"); + assert!(capture.transcript.contains("bye")); +} + +#[test] +fn closes_a_child_that_never_exits_on_its_own() { + let mut session = open_terminal(shell_options( + "printf 'waiting for input\\n'; while true; do sleep 0.05; done", + )) + .expect("session opens"); + session + .wait_for( + &TerminalPattern::text("waiting for input"), + Duration::ZERO, + Some(Duration::from_secs(5)), + ) + .expect("prompt arrives"); + let capture = session.close().expect("session closes"); + assert!(capture.transcript.contains("waiting for input")); +} + +#[test] +fn requires_a_file_for_both_entry_points() { + let options = TerminalCaptureOptions::default(); + assert!(capture_terminal(options.clone()) + .expect_err("capture rejects an empty file") + .to_string() + .contains("capture_terminal requires a file")); + assert!(open_terminal(options) + .err() + .expect("session rejects an empty file") + .to_string() + .contains("open_terminal requires a file")); +}