@archastro/tui-shot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArchAstro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @archastro/tui-shot
2
+
3
+ The terminal rendering engine behind `@archastro/astroshot`. It supports two
4
+ capture boundaries:
5
+
6
+ - Ink fixture trees rendered in-process and interpreted with xterm.
7
+ - Arbitrary executables launched in a real pseudoterminal, driven by scripted
8
+ input, interpreted with the same xterm model, and captured in Chromium.
9
+
10
+ Use the unified `@archastro/astroshot` package for command-line capture.
11
+
12
+ ## Quick start
13
+
14
+ Run from a Node.js 22.14 or newer project. Install the project-local peers only
15
+ when using Ink mode:
16
+
17
+ ```bash
18
+ npm install --save-dev ink@^7.1 react@^19
19
+ npx --@archastro:registry=https://registry.npmjs.org @archastro/astroshot install-browser
20
+ npx --@archastro:registry=https://registry.npmjs.org @archastro/astroshot init ink
21
+ npx --@archastro:registry=https://registry.npmjs.org @archastro/astroshot init pty
22
+ ```
23
+
24
+ Then capture either boundary:
25
+
26
+ ```bash
27
+ npx --@archastro:registry=https://registry.npmjs.org \
28
+ @archastro/astroshot ink ./ink.shot.tsx -o ./ink.png
29
+ npx --@archastro:registry=https://registry.npmjs.org \
30
+ @archastro/astroshot pty ./pty.shot.yaml -o ./ratatui.png
31
+ ```
32
+
33
+ On Linux hosts missing Chromium's system libraries, install the browser with
34
+ `install-browser --with-deps`.
35
+
36
+ ## Ink fixture contract
37
+
38
+ ```tsx
39
+ import { Box, Text } from "ink";
40
+ import React from "react";
41
+ import type { InkShotFixture } from "@archastro/astroshot/ink";
42
+
43
+ export default {
44
+ cols: 80,
45
+ rows: 12,
46
+ background: "#090a12",
47
+ scale: 2,
48
+ expectText: ["Ready to deploy"],
49
+ component: (
50
+ <Box borderStyle="round">
51
+ <Text color="#b9a8ff">Ready to deploy</Text>
52
+ </Box>
53
+ ),
54
+ } satisfies InkShotFixture;
55
+ ```
56
+
57
+ `expectText` makes capture fail unless every listed string exists in the final
58
+ terminal screen. `--cols`, `--rows`, and `--scale` override fixture values.
59
+ Ink capture also supports `batch <manifest.yaml|json>`; manifest paths resolve
60
+ relative to the manifest.
61
+
62
+ ## PTY fixture contract
63
+
64
+ PTY fixtures work with any executable that behaves like a terminal program:
65
+
66
+ ```yaml
67
+ version: 1
68
+ command: ./target/debug/my-ratatui-app
69
+ args: []
70
+ cwd: .
71
+ cols: 100
72
+ rows: 30
73
+ timeoutMs: 15000
74
+ settleMs: 100
75
+ actions:
76
+ - waitFor: Choose an option
77
+ - key: down
78
+ - key: enter
79
+ - waitFor: Ready
80
+ expectText:
81
+ - Ready
82
+ ```
83
+
84
+ The child receives `TERM=xterm-256color`, a fixed terminal grid, and the
85
+ fixture environment. `command` is spawned directly—not through a shell—and
86
+ `cwd` resolves relative to the fixture. On Windows, use an `.exe`; `.cmd` and
87
+ `.bat` scripts are rejected because they require a shell. Supported actions
88
+ are:
89
+
90
+ - `waitFor` with an optional per-action `timeoutMs`;
91
+ - `waitForExit: true` with an optional `timeoutMs` when the documented program
92
+ is expected to terminate before capture;
93
+ - `key`: `enter`, arrows, `tab`, `escape`, `backspace`, `space`, `ctrl-c`, or
94
+ `ctrl-d`;
95
+ - `text` for literal input;
96
+ - `pauseMs` for a bounded delay.
97
+
98
+ Actions run in order. The final xterm screen must contain every `expectText`
99
+ value before Chromium captures it. Astroshot terminates a still-running child
100
+ after the screenshot.
101
+
102
+ A child that exits nonzero before capture fails by default, even when expected
103
+ text rendered. Set `allowNonZeroExit: true` only to document an intentional
104
+ failure state. Use a final `waitForExit: true` action when process completion is
105
+ part of the documented state; this waits for the authoritative child status
106
+ instead of relying on a fixed settle delay.
107
+
108
+ The renderer targets text and ANSI/VT terminal interfaces. Mouse events,
109
+ mid-journey resize actions, and terminal graphics protocols such as Sixel or
110
+ Kitty images are not currently modeled.
111
+
112
+ `node-pty` is an optional, lazily loaded native dependency so React and Ink
113
+ capture still work on an unsupported PTY host. This release pins the exact
114
+ official `1.2.0-beta.14` build; PTY capture reports an actionable error if its
115
+ native addon is unavailable.
116
+
117
+ ## Reproducibility and trust
118
+
119
+ Playwright is pinned so each release selects one Chromium version. PNG bytes
120
+ can still differ across operating systems because the default stage uses host
121
+ fonts. Set `fontFamily` and use a consistent OS/container for pixel baselines.
122
+
123
+ Ink fixture modules and PTY commands are executable code with the current
124
+ user's permissions. Only capture trusted repositories. A PTY is an I/O
125
+ boundary, not a security sandbox; use a container for untrusted programs.
126
+
127
+ ## Programmatic API
128
+
129
+ ```ts
130
+ import {
131
+ closeSharedBrowser,
132
+ takePtyShot,
133
+ takeTuiShot,
134
+ } from "@archastro/tui-shot";
135
+
136
+ await takeTuiShot({
137
+ fixturePath: "./screenshots/welcome.tsx",
138
+ outPath: "./screenshots/welcome.png",
139
+ });
140
+ await takePtyShot({
141
+ fixturePath: "./screenshots/ratatui.yaml",
142
+ outPath: "./screenshots/ratatui.png",
143
+ });
144
+ await closeSharedBrowser();
145
+ ```
146
+
147
+ Ink fixtures support Ink 7 and React 19 as peer dependencies.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+
8
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
9
+ const cli = path.join(root, "dist", "cli.js");
10
+ const require = createRequire(import.meta.url);
11
+ const tsx = require.resolve("tsx/cli");
12
+ const result = spawnSync(process.execPath, [tsx, cli, ...process.argv.slice(2)], {
13
+ stdio: "inherit",
14
+ });
15
+
16
+ if (result.error) {
17
+ console.error(`tui-shot could not start: ${result.error.message}`);
18
+ process.exit(1);
19
+ }
20
+ if (result.signal) {
21
+ process.kill(process.pid, result.signal);
22
+ }
23
+ process.exit(result.status ?? 1);
@@ -0,0 +1,3 @@
1
+ import type { BatchEntry } from "./types.js";
2
+ /** Resolve batch destinations once, rejecting traversal and overwrite risks. */
3
+ export declare function resolveBatchOutputPaths(entries: BatchEntry[], manifestDir: string, outDir: string | null): string[];
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ function collisionKey(filePath) {
3
+ // Reject case-only collisions on every platform so a manifest behaves the
4
+ // same on case-sensitive and case-insensitive filesystems.
5
+ return path.resolve(filePath).normalize("NFC").toLowerCase();
6
+ }
7
+ function safeRelativeOutput(output) {
8
+ const portable = output.replaceAll("\\", "/");
9
+ const normalized = path.posix.normalize(portable);
10
+ if (path.posix.isAbsolute(normalized) ||
11
+ path.win32.isAbsolute(output) ||
12
+ normalized === ".." ||
13
+ normalized.startsWith("../")) {
14
+ throw new Error(`Batch output must be a safe relative path when --out-dir is used: ${output}`);
15
+ }
16
+ return normalized;
17
+ }
18
+ /** Resolve batch destinations once, rejecting traversal and overwrite risks. */
19
+ export function resolveBatchOutputPaths(entries, manifestDir, outDir) {
20
+ const destinations = entries.map((entry) => outDir
21
+ ? path.resolve(outDir, safeRelativeOutput(entry.out))
22
+ : path.resolve(manifestDir, entry.out));
23
+ const seen = new Map();
24
+ for (const destination of destinations) {
25
+ const key = collisionKey(destination);
26
+ const previous = seen.get(key);
27
+ if (previous) {
28
+ throw new Error(`Batch outputs resolve to the same destination: ${previous} and ${destination}`);
29
+ }
30
+ seen.set(key, destination);
31
+ }
32
+ return destinations;
33
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import YAML from "yaml";
7
+ import { resolveBatchOutputPaths } from "./batch-paths.js";
8
+ import { takePtyShot } from "./pty-shot.js";
9
+ import { closeSharedBrowser, takeTuiShot } from "./shot.js";
10
+ const localRequire = createRequire(import.meta.url);
11
+ function help() {
12
+ console.log(`tui-shot — deterministic PNG screenshots of terminal interfaces
13
+
14
+ Usage:
15
+ tui-shot install-browser [--with-deps]
16
+ tui-shot shot <fixture.tsx> -o <out.png> [options]
17
+ tui-shot pty <fixture.yaml|json> -o <out.png> [options]
18
+ tui-shot batch <manifest.yaml|json> [options]
19
+
20
+ Options:
21
+ -o, --out <path> Output PNG path
22
+ --cols <count> Override terminal columns
23
+ --rows <count> Override terminal rows
24
+ --scale <factor> Override PNG device scale factor
25
+ --out-dir <path> Override batch output directory
26
+ --headed Show Chromium while capturing
27
+ --with-deps Install Chromium system dependencies (Linux)
28
+ -h, --help Show this help
29
+ `);
30
+ }
31
+ function takeValue(args, flag) {
32
+ const value = args.shift();
33
+ if (!value || value.startsWith("-")) {
34
+ throw new Error(`${flag} requires a value`);
35
+ }
36
+ return value;
37
+ }
38
+ function parseArgs(argv) {
39
+ const args = [...argv];
40
+ const flags = {};
41
+ const positionals = [];
42
+ while (args.length) {
43
+ const value = args.shift();
44
+ if (value === "-h" || value === "--help")
45
+ flags.help = true;
46
+ else if (value === "--headed")
47
+ flags.headed = true;
48
+ else if (value === "--with-deps")
49
+ flags.withDeps = true;
50
+ else if (value === "-o" || value === "--out")
51
+ flags.out = takeValue(args, value);
52
+ else if (value === "--cols")
53
+ flags.cols = takeValue(args, value);
54
+ else if (value === "--rows")
55
+ flags.rows = takeValue(args, value);
56
+ else if (value === "--scale")
57
+ flags.scale = takeValue(args, value);
58
+ else if (value === "--out-dir")
59
+ flags.outDir = takeValue(args, value);
60
+ else if (value.startsWith("-"))
61
+ throw new Error(`Unknown flag: ${value}`);
62
+ else
63
+ positionals.push(value);
64
+ }
65
+ return { flags, positionals };
66
+ }
67
+ function numberFlag(flags, key, options) {
68
+ const raw = flags[key];
69
+ if (raw === undefined)
70
+ return undefined;
71
+ const value = Number(raw);
72
+ if (!Number.isFinite(value) ||
73
+ value <= 0 ||
74
+ value > options.maximum ||
75
+ (options.integer && !Number.isInteger(value))) {
76
+ const kind = options.integer ? "positive integer" : "positive number";
77
+ throw new Error(`--${key} must be a ${kind} no greater than ${options.maximum}`);
78
+ }
79
+ return value;
80
+ }
81
+ function captureOverrides(flags) {
82
+ return {
83
+ cols: numberFlag(flags, "cols", { integer: true, maximum: 1_000 }),
84
+ rows: numberFlag(flags, "rows", { integer: true, maximum: 1_000 }),
85
+ scale: numberFlag(flags, "scale", { maximum: 4 }),
86
+ };
87
+ }
88
+ function assertPngPath(outPath) {
89
+ if (path.extname(outPath).toLowerCase() !== ".png") {
90
+ throw new Error(`Output must use a .png extension: ${outPath}`);
91
+ }
92
+ }
93
+ function installBrowser(withDeps) {
94
+ const playwrightRoot = path.dirname(localRequire.resolve("playwright/package.json"));
95
+ const result = spawnSync(process.execPath, [
96
+ path.join(playwrightRoot, "cli.js"),
97
+ "install",
98
+ ...(withDeps ? ["--with-deps"] : []),
99
+ "chromium",
100
+ ], { stdio: "inherit" });
101
+ if (result.error) {
102
+ throw new Error(`Could not start Playwright installer: ${result.error.message}`);
103
+ }
104
+ if (result.status !== 0) {
105
+ throw new Error(`Playwright browser installation exited with status ${result.status}`);
106
+ }
107
+ }
108
+ async function shot(fixturePath, flags) {
109
+ const outPath = flags.out;
110
+ if (typeof outPath !== "string") {
111
+ throw new Error("shot requires -o <out.png>");
112
+ }
113
+ assertPngPath(outPath);
114
+ const written = await takeTuiShot({
115
+ fixturePath,
116
+ outPath,
117
+ headed: Boolean(flags.headed),
118
+ ...captureOverrides(flags),
119
+ });
120
+ console.log(`wrote ${written}`);
121
+ }
122
+ async function pty(fixturePath, flags) {
123
+ const outPath = flags.out;
124
+ if (typeof outPath !== "string") {
125
+ throw new Error("pty requires -o <out.png>");
126
+ }
127
+ assertPngPath(outPath);
128
+ const written = await takePtyShot({
129
+ fixturePath,
130
+ outPath,
131
+ headed: Boolean(flags.headed),
132
+ ...captureOverrides(flags),
133
+ });
134
+ console.log(`wrote ${written}`);
135
+ }
136
+ function parseManifest(absolute) {
137
+ const raw = fs.readFileSync(absolute, "utf8");
138
+ const value = absolute.endsWith(".json")
139
+ ? JSON.parse(raw)
140
+ : YAML.parse(raw);
141
+ if (!value || typeof value !== "object" || !Array.isArray(value.shots)) {
142
+ throw new Error(`No shots listed in ${absolute}`);
143
+ }
144
+ const manifest = value;
145
+ if (manifest.shots.length === 0 ||
146
+ manifest.shots.some((entry) => !entry ||
147
+ typeof entry.fixture !== "string" ||
148
+ !entry.fixture ||
149
+ typeof entry.out !== "string" ||
150
+ !entry.out)) {
151
+ throw new Error(`Every shot in ${absolute} needs fixture and out paths`);
152
+ }
153
+ return manifest;
154
+ }
155
+ async function batch(manifestPath, flags) {
156
+ const absolute = path.resolve(manifestPath);
157
+ const manifest = parseManifest(absolute);
158
+ const base = path.dirname(absolute);
159
+ const outDir = typeof flags.outDir === "string" ? path.resolve(flags.outDir) : null;
160
+ const outPaths = resolveBatchOutputPaths(manifest.shots, base, outDir);
161
+ for (const outPath of outPaths)
162
+ assertPngPath(outPath);
163
+ const overrides = captureOverrides(flags);
164
+ let completed = 0;
165
+ for (const [index, entry] of manifest.shots.entries()) {
166
+ const fixturePath = path.resolve(base, entry.fixture);
167
+ const outPath = outPaths[index];
168
+ process.stdout.write(`shot ${path.relative(process.cwd(), fixturePath)} … `);
169
+ await takeTuiShot({
170
+ fixturePath,
171
+ outPath,
172
+ headed: Boolean(flags.headed),
173
+ ...overrides,
174
+ });
175
+ console.log(`→ ${path.relative(process.cwd(), outPath)}`);
176
+ completed++;
177
+ }
178
+ console.log(`done: ${completed}/${manifest.shots.length} shots`);
179
+ }
180
+ async function main() {
181
+ const argv = process.argv.slice(2);
182
+ const command = argv[0];
183
+ if (!command || command === "help" || command === "-h" || command === "--help") {
184
+ help();
185
+ if (!command)
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ const { flags, positionals } = parseArgs(argv.slice(1));
190
+ if (flags.help) {
191
+ help();
192
+ return;
193
+ }
194
+ if (command === "install-browser") {
195
+ if (positionals.length) {
196
+ throw new Error("install-browser does not accept arguments");
197
+ }
198
+ installBrowser(Boolean(flags.withDeps));
199
+ }
200
+ else if (command === "shot") {
201
+ if (!positionals[0])
202
+ throw new Error("shot requires a fixture path");
203
+ await shot(positionals[0], flags);
204
+ }
205
+ else if (command === "pty") {
206
+ if (!positionals[0])
207
+ throw new Error("pty requires a fixture path");
208
+ await pty(positionals[0], flags);
209
+ }
210
+ else if (command === "batch") {
211
+ if (!positionals[0])
212
+ throw new Error("batch requires a manifest path");
213
+ await batch(positionals[0], flags);
214
+ }
215
+ else {
216
+ throw new Error(`Unknown command: ${command}`);
217
+ }
218
+ }
219
+ main()
220
+ .catch((error) => {
221
+ console.error(error instanceof Error ? error.message : error);
222
+ process.exitCode = 1;
223
+ })
224
+ .finally(closeSharedBrowser);
@@ -0,0 +1,3 @@
1
+ export { closeSharedBrowser, takeTuiShot } from "./shot.js";
2
+ export { takePtyShot } from "./pty-shot.js";
3
+ export type { BatchEntry, BatchManifest, PtyAction, PtyKey, PtyShotFixture, PtyShotRequest, TuiShotFixture, TuiShotRequest, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { closeSharedBrowser, takeTuiShot } from "./shot.js";
2
+ export { takePtyShot } from "./pty-shot.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ const [, , token, statusPath, command, ...args] = process.argv;
4
+ if (!token || !statusPath || !command) {
5
+ process.stderr.write("Astroshot PTY status wrapper requires a token, status path, and command.\n");
6
+ process.exit(2);
7
+ }
8
+ const child = spawn(command, args, {
9
+ cwd: process.cwd(),
10
+ env: process.env,
11
+ stdio: "inherit",
12
+ windowsHide: true,
13
+ });
14
+ let reported = false;
15
+ // Terminal-generated signals are delivered to the PTY process group. Keep the
16
+ // bridge alive so the target's exit callback can report its authoritative code.
17
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
18
+ process.on(signal, () => { });
19
+ }
20
+ function reportExit(code) {
21
+ if (reported)
22
+ return;
23
+ reported = true;
24
+ const temporaryStatusPath = `${statusPath}.${process.pid}.tmp`;
25
+ try {
26
+ fs.writeFileSync(temporaryStatusPath, String(code), { mode: 0o600 });
27
+ fs.renameSync(temporaryStatusPath, statusPath);
28
+ }
29
+ catch (error) {
30
+ process.stderr.write(`Unable to write PTY program exit status: ${error instanceof Error ? error.message : String(error)}\n`);
31
+ }
32
+ const marker = `\x1b]777;astroshot-exit-${token}=${code}\x07`;
33
+ const writeMarker = () => {
34
+ process.stdout.write(marker, () => process.exit(0));
35
+ };
36
+ const markerDelay = Number(process.env.ASTROSHOT_TEST_DELAY_PTY_EXIT_MARKER_MS ?? 0);
37
+ if (Number.isFinite(markerDelay) && markerDelay > 0) {
38
+ setTimeout(writeMarker, markerDelay);
39
+ }
40
+ else {
41
+ writeMarker();
42
+ }
43
+ }
44
+ child.once("error", (error) => {
45
+ process.stderr.write(`Unable to launch PTY program: ${error.message}\n`);
46
+ reportExit(127);
47
+ });
48
+ child.once("exit", (code) => {
49
+ reportExit(code ?? 1);
50
+ });
@@ -0,0 +1,2 @@
1
+ import type { PtyShotRequest } from "./types.js";
2
+ export declare function takePtyShot(request: PtyShotRequest): Promise<string>;