@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/dist/shot.js ADDED
@@ -0,0 +1,250 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { chromium } from "playwright";
6
+ import { tsImport } from "tsx/esm/api";
7
+ import { renderInkFrame } from "./render-ink.js";
8
+ import { ansiFrameToHtml } from "./terminal-html.js";
9
+ let sharedBrowser = null;
10
+ let sharedBrowserHeaded = null;
11
+ let shotQueue = Promise.resolve();
12
+ async function browserFor(headed) {
13
+ if (sharedBrowser?.isConnected() && sharedBrowserHeaded === headed) {
14
+ return sharedBrowser;
15
+ }
16
+ if (sharedBrowser)
17
+ await closeBrowserNow();
18
+ try {
19
+ sharedBrowser = await chromium.launch({ headless: !headed });
20
+ sharedBrowserHeaded = headed;
21
+ }
22
+ catch (error) {
23
+ const detail = error instanceof Error ? error.message : String(error);
24
+ if (detail.includes("Executable doesn't exist")) {
25
+ throw new Error("Chromium is not installed. Run `npx --@archastro:registry=https://registry.npmjs.org @archastro/astroshot install-browser` and retry.", { cause: error });
26
+ }
27
+ throw error;
28
+ }
29
+ return sharedBrowser;
30
+ }
31
+ async function closeBrowserNow() {
32
+ if (!sharedBrowser)
33
+ return;
34
+ try {
35
+ await sharedBrowser.close();
36
+ }
37
+ finally {
38
+ sharedBrowser = null;
39
+ sharedBrowserHeaded = null;
40
+ }
41
+ }
42
+ export function closeSharedBrowser() {
43
+ const close = shotQueue.then(closeBrowserNow);
44
+ shotQueue = close.then(() => undefined, () => undefined);
45
+ return close;
46
+ }
47
+ async function loadFixture(fixturePath) {
48
+ const absolute = path.resolve(fixturePath);
49
+ if (!fs.existsSync(absolute)) {
50
+ throw new Error(`Fixture not found: ${absolute}`);
51
+ }
52
+ const fixtureRequire = createRequire(absolute);
53
+ let inkEntry;
54
+ let reactEntry;
55
+ try {
56
+ inkEntry = fixtureRequire.resolve("ink");
57
+ reactEntry = fixtureRequire.resolve("react");
58
+ }
59
+ catch (error) {
60
+ throw new Error(`Could not resolve Ink and React next to fixture ${absolute}. Install ink@7 and react@19 in the fixture project.`, { cause: error });
61
+ }
62
+ const inkRequire = createRequire(inkEntry);
63
+ const [module, inkModule, reactModule, chalkModule] = await Promise.all([
64
+ tsImport(absolute, import.meta.url),
65
+ import(pathToFileURL(inkEntry).href),
66
+ import(pathToFileURL(reactEntry).href),
67
+ import(pathToFileURL(inkRequire.resolve("chalk")).href),
68
+ ]);
69
+ const fixtureModule = module;
70
+ const fixture = fixtureModule.default ?? fixtureModule.fixture;
71
+ if (!fixture?.component) {
72
+ throw new Error(`Fixture ${absolute} must default-export a TuiShotFixture with a component.`);
73
+ }
74
+ return {
75
+ fixture,
76
+ ink: { render: inkModule.render, chalk: chalkModule.default },
77
+ react: reactModule.default ?? reactModule,
78
+ };
79
+ }
80
+ export function validPositive(value, name, options) {
81
+ if (!Number.isFinite(value) ||
82
+ value <= 0 ||
83
+ value > options.maximum ||
84
+ (options.integer && !Number.isInteger(value))) {
85
+ throw new Error(`${name} must be a positive${options.integer ? " integer" : ""} no greater than ${options.maximum}`);
86
+ }
87
+ return value;
88
+ }
89
+ export async function captureTerminalHtml(request) {
90
+ const { terminalRows, cols, rows, scale, background, foreground, fontFamily, fontSize, lineHeight, padding, borderRadius, } = request;
91
+ const cssWidth = Math.ceil(cols * fontSize * 0.62 + padding * 2);
92
+ const cssHeight = Math.ceil(rows * fontSize * lineHeight + padding * 2);
93
+ const outPath = path.resolve(request.outPath);
94
+ if (path.extname(outPath).toLowerCase() !== ".png") {
95
+ throw new Error(`Output must use a .png extension: ${outPath}`);
96
+ }
97
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
98
+ const browser = await browserFor(Boolean(request.headed));
99
+ let page;
100
+ try {
101
+ page = await browser.newPage({
102
+ viewport: { width: cssWidth + 32, height: cssHeight + 32 },
103
+ deviceScaleFactor: scale,
104
+ });
105
+ }
106
+ catch (error) {
107
+ await closeBrowserNow();
108
+ throw error;
109
+ }
110
+ try {
111
+ await page.setContent(`<!doctype html>
112
+ <html>
113
+ <head>
114
+ <meta charset="utf-8" />
115
+ <style>
116
+ html, body { margin: 0; padding: 0; background: transparent; }
117
+ body { display: inline-block; padding: 16px; }
118
+ [data-tui-shot] {
119
+ box-sizing: border-box;
120
+ width: ${cssWidth}px;
121
+ height: ${cssHeight}px;
122
+ overflow: hidden;
123
+ padding: ${padding}px;
124
+ border: 1px solid rgba(185, 168, 255, .18);
125
+ border-radius: ${borderRadius}px;
126
+ background: ${background};
127
+ color: ${foreground};
128
+ box-shadow: 0 18px 44px rgba(0, 0, 0, .28);
129
+ font-family: ${fontFamily};
130
+ font-size: ${fontSize}px;
131
+ font-variant-ligatures: none;
132
+ font-weight: 400;
133
+ line-height: ${lineHeight};
134
+ text-rendering: geometricPrecision;
135
+ }
136
+ .tui-row {
137
+ height: ${lineHeight}em;
138
+ overflow: hidden;
139
+ white-space: pre;
140
+ }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <div data-tui-shot role="img" aria-label="Terminal screenshot">${terminalRows}</div>
145
+ </body>
146
+ </html>`, { waitUntil: "load" });
147
+ await page.locator("[data-tui-shot]").screenshot({
148
+ path: outPath,
149
+ omitBackground: true,
150
+ });
151
+ }
152
+ finally {
153
+ await page.close();
154
+ }
155
+ return outPath;
156
+ }
157
+ async function takeIsolatedTuiShot(request) {
158
+ const context = await loadFixture(request.fixturePath);
159
+ const previousReact = Object.getOwnPropertyDescriptor(globalThis, "React");
160
+ Object.defineProperty(globalThis, "React", {
161
+ configurable: true,
162
+ value: context.react,
163
+ writable: true,
164
+ });
165
+ try {
166
+ return await renderTuiShot(request, context);
167
+ }
168
+ finally {
169
+ if (previousReact) {
170
+ Object.defineProperty(globalThis, "React", previousReact);
171
+ }
172
+ else {
173
+ Reflect.deleteProperty(globalThis, "React");
174
+ }
175
+ }
176
+ }
177
+ async function renderTuiShot(request, context) {
178
+ const { fixture } = context;
179
+ const cols = validPositive(request.cols ?? fixture.cols ?? 100, "cols", {
180
+ integer: true,
181
+ maximum: 1_000,
182
+ });
183
+ const rows = validPositive(request.rows ?? fixture.rows ?? 30, "rows", {
184
+ integer: true,
185
+ maximum: 1_000,
186
+ });
187
+ const scale = validPositive(request.scale ?? fixture.scale ?? 2, "scale", {
188
+ maximum: 4,
189
+ });
190
+ const background = fixture.background ?? "#090a12";
191
+ const foreground = fixture.foreground ?? "#e8e8f2";
192
+ const fontFamily = fixture.fontFamily ??
193
+ '"SFMono-Regular", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace';
194
+ const fontSize = validPositive(fixture.fontSize ?? 15, "fontSize", {
195
+ maximum: 200,
196
+ });
197
+ const lineHeight = validPositive(fixture.lineHeight ?? 1.32, "lineHeight", {
198
+ maximum: 10,
199
+ });
200
+ const padding = fixture.padding ?? 22;
201
+ const borderRadius = fixture.borderRadius ?? 12;
202
+ if (!Number.isFinite(padding) || padding < 0 || padding > 1_000) {
203
+ throw new Error("padding must be between 0 and 1000");
204
+ }
205
+ if (!Number.isFinite(borderRadius) ||
206
+ borderRadius < 0 ||
207
+ borderRadius > 1_000) {
208
+ throw new Error("borderRadius must be between 0 and 1000");
209
+ }
210
+ const ansi = renderInkFrame(fixture, cols, rows, context.ink);
211
+ const plainFrame = ansi
212
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
213
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
214
+ .replace(/\x1b[@-_]/g, "");
215
+ for (const expected of fixture.expectText ?? []) {
216
+ if (!plainFrame.includes(expected)) {
217
+ throw new Error(`Fixture did not render expected text ${JSON.stringify(expected)}. ` +
218
+ `Visible frame:\n${plainFrame.slice(0, 1_200)}`);
219
+ }
220
+ }
221
+ const terminalRows = await ansiFrameToHtml(ansi, {
222
+ cols,
223
+ rows,
224
+ foreground,
225
+ background,
226
+ });
227
+ return captureTerminalHtml({
228
+ terminalRows,
229
+ outPath: request.outPath,
230
+ headed: request.headed,
231
+ cols,
232
+ rows,
233
+ scale,
234
+ background,
235
+ foreground,
236
+ fontFamily,
237
+ fontSize,
238
+ lineHeight,
239
+ padding,
240
+ borderRadius,
241
+ });
242
+ }
243
+ export function queueTerminalShot(task) {
244
+ const shot = shotQueue.then(task);
245
+ shotQueue = shot.then(() => undefined, () => undefined);
246
+ return shot;
247
+ }
248
+ export function takeTuiShot(request) {
249
+ return queueTerminalShot(() => takeIsolatedTuiShot(request));
250
+ }
@@ -0,0 +1,14 @@
1
+ import { type Terminal as TerminalType } from "@xterm/headless";
2
+ export type HeadlessTerminal = TerminalType;
3
+ export interface TerminalHtmlOptions {
4
+ cols: number;
5
+ rows: number;
6
+ foreground: string;
7
+ background: string;
8
+ }
9
+ export declare function createHeadlessTerminal(cols: number, rows: number): HeadlessTerminal;
10
+ export declare function writeTerminal(terminal: HeadlessTerminal, data: string): Promise<void>;
11
+ export declare function terminalPlainText(terminal: HeadlessTerminal, rows: number): string;
12
+ export declare function terminalToHtml(terminal: HeadlessTerminal, options: TerminalHtmlOptions): string;
13
+ /** Interpret a real ANSI frame and return styled HTML rows from xterm's cells. */
14
+ export declare function ansiFrameToHtml(ansi: string, options: TerminalHtmlOptions): Promise<string>;
@@ -0,0 +1,144 @@
1
+ import xtermHeadless from "@xterm/headless";
2
+ const { Terminal } = xtermHeadless;
3
+ const ANSI_16 = [
4
+ "#2b2f3a", "#f0727a", "#54e0a0", "#f0b86e",
5
+ "#7aa2f7", "#b9a8ff", "#5bd6e0", "#e8e8f2",
6
+ "#5a5a7a", "#ff8d94", "#78efb6", "#ffd08a",
7
+ "#9bbcff", "#d1c5ff", "#86eaf0", "#ffffff",
8
+ ];
9
+ function rgb(red, green, blue) {
10
+ return `#${[red, green, blue]
11
+ .map((value) => value.toString(16).padStart(2, "0"))
12
+ .join("")}`;
13
+ }
14
+ function paletteColor(index) {
15
+ if (index < ANSI_16.length)
16
+ return ANSI_16[index];
17
+ if (index < 232) {
18
+ const value = index - 16;
19
+ const levels = [0, 95, 135, 175, 215, 255];
20
+ return rgb(levels[Math.floor(value / 36)], levels[Math.floor((value % 36) / 6)], levels[value % 6]);
21
+ }
22
+ const gray = 8 + (index - 232) * 10;
23
+ return rgb(gray, gray, gray);
24
+ }
25
+ function packedRgb(value) {
26
+ return rgb((value >> 16) & 255, (value >> 8) & 255, value & 255);
27
+ }
28
+ function escapeHtml(value) {
29
+ return value
30
+ .replaceAll("&", "&amp;")
31
+ .replaceAll("<", "&lt;")
32
+ .replaceAll(">", "&gt;")
33
+ .replaceAll('"', "&quot;");
34
+ }
35
+ function cellStyle(cell, defaultForeground, defaultBackground) {
36
+ let foreground = cell.isFgRGB()
37
+ ? packedRgb(cell.getFgColor())
38
+ : cell.isFgPalette()
39
+ ? paletteColor(cell.getFgColor())
40
+ : defaultForeground;
41
+ let background = cell.isBgRGB()
42
+ ? packedRgb(cell.getBgColor())
43
+ : cell.isBgPalette()
44
+ ? paletteColor(cell.getBgColor())
45
+ : defaultBackground;
46
+ if (cell.isInverse())
47
+ [foreground, background] = [background, foreground];
48
+ return {
49
+ foreground,
50
+ background,
51
+ bold: Boolean(cell.isBold()),
52
+ dim: Boolean(cell.isDim()),
53
+ italic: Boolean(cell.isItalic()),
54
+ underline: Boolean(cell.isUnderline()),
55
+ strike: Boolean(cell.isStrikethrough()),
56
+ invisible: Boolean(cell.isInvisible()),
57
+ };
58
+ }
59
+ function styleAttribute(style) {
60
+ return [
61
+ `color:${style.invisible ? style.background : style.foreground}`,
62
+ `background:${style.background}`,
63
+ style.bold ? "font-weight:700" : "",
64
+ style.dim ? "opacity:.62" : "",
65
+ style.italic ? "font-style:italic" : "",
66
+ style.underline && style.strike
67
+ ? "text-decoration:underline line-through"
68
+ : style.underline
69
+ ? "text-decoration:underline"
70
+ : style.strike
71
+ ? "text-decoration:line-through"
72
+ : "",
73
+ ]
74
+ .filter(Boolean)
75
+ .join(";");
76
+ }
77
+ export function createHeadlessTerminal(cols, rows) {
78
+ return new Terminal({
79
+ cols,
80
+ rows,
81
+ allowProposedApi: true,
82
+ convertEol: true,
83
+ scrollback: 0,
84
+ });
85
+ }
86
+ export function writeTerminal(terminal, data) {
87
+ return new Promise((resolve) => terminal.write(data, resolve));
88
+ }
89
+ export function terminalPlainText(terminal, rows) {
90
+ const buffer = terminal.buffer.active;
91
+ const lines = [];
92
+ for (let y = 0; y < rows; y++) {
93
+ const line = buffer.getLine(buffer.viewportY + y);
94
+ lines.push(line?.translateToString(true) ?? "");
95
+ }
96
+ return lines.join("\n").trimEnd();
97
+ }
98
+ export function terminalToHtml(terminal, options) {
99
+ const buffer = terminal.buffer.active;
100
+ const rows = [];
101
+ for (let y = 0; y < options.rows; y++) {
102
+ const line = buffer.getLine(buffer.viewportY + y);
103
+ const runs = [];
104
+ for (let x = 0; x < options.cols; x++) {
105
+ const cell = line?.getCell(x);
106
+ if (cell?.getWidth() === 0)
107
+ continue;
108
+ const style = cell
109
+ ? cellStyle(cell, options.foreground, options.background)
110
+ : {
111
+ foreground: options.foreground,
112
+ background: options.background,
113
+ bold: false,
114
+ dim: false,
115
+ italic: false,
116
+ underline: false,
117
+ strike: false,
118
+ invisible: false,
119
+ };
120
+ const key = JSON.stringify(style);
121
+ const text = cell?.getChars() || " ";
122
+ const previous = runs.at(-1);
123
+ if (previous?.key === key)
124
+ previous.text += text;
125
+ else
126
+ runs.push({ key, style, text });
127
+ }
128
+ rows.push(`<div class="tui-row">${runs
129
+ .map((run) => `<span style="${styleAttribute(run.style)}">${escapeHtml(run.text)}</span>`)
130
+ .join("")}</div>`);
131
+ }
132
+ return rows.join("");
133
+ }
134
+ /** Interpret a real ANSI frame and return styled HTML rows from xterm's cells. */
135
+ export async function ansiFrameToHtml(ansi, options) {
136
+ const terminal = createHeadlessTerminal(options.cols, options.rows);
137
+ try {
138
+ await writeTerminal(terminal, `\x1b[?7l${ansi}`);
139
+ return terminalToHtml(terminal, options);
140
+ }
141
+ finally {
142
+ terminal.dispose();
143
+ }
144
+ }
@@ -0,0 +1,81 @@
1
+ import type { ReactElement } from "react";
2
+ export interface TuiShotFixture {
3
+ /** Real Ink tree rendered into the terminal frame. */
4
+ component: ReactElement;
5
+ /** Visible strings that must exist before a PNG is accepted. */
6
+ expectText?: string[];
7
+ /** Terminal grid dimensions, not image pixels. */
8
+ cols?: number;
9
+ rows?: number;
10
+ background?: string;
11
+ foreground?: string;
12
+ fontFamily?: string;
13
+ fontSize?: number;
14
+ lineHeight?: number;
15
+ padding?: number;
16
+ borderRadius?: number;
17
+ /** PNG device scale factor. Defaults to 2. */
18
+ scale?: number;
19
+ }
20
+ export interface TuiShotRequest {
21
+ fixturePath: string;
22
+ outPath: string;
23
+ headed?: boolean;
24
+ cols?: number;
25
+ rows?: number;
26
+ scale?: number;
27
+ }
28
+ export type PtyKey = "enter" | "up" | "down" | "left" | "right" | "tab" | "escape" | "backspace" | "space" | "ctrl-c" | "ctrl-d";
29
+ export type PtyAction = {
30
+ waitFor: string;
31
+ timeoutMs?: number;
32
+ } | {
33
+ waitForExit: true;
34
+ timeoutMs?: number;
35
+ } | {
36
+ key: PtyKey;
37
+ } | {
38
+ text: string;
39
+ } | {
40
+ pauseMs: number;
41
+ };
42
+ export interface PtyShotFixture {
43
+ version: 1;
44
+ /** Executable launched directly, without an intermediary shell. */
45
+ command: string;
46
+ args?: string[];
47
+ /** Working directory, relative to the fixture file by default. */
48
+ cwd?: string;
49
+ env?: Record<string, string>;
50
+ cols?: number;
51
+ rows?: number;
52
+ timeoutMs?: number;
53
+ settleMs?: number;
54
+ /** Permit a child that exits nonzero before capture. Defaults to false. */
55
+ allowNonZeroExit?: boolean;
56
+ actions?: PtyAction[];
57
+ expectText?: string[];
58
+ background?: string;
59
+ foreground?: string;
60
+ fontFamily?: string;
61
+ fontSize?: number;
62
+ lineHeight?: number;
63
+ padding?: number;
64
+ borderRadius?: number;
65
+ scale?: number;
66
+ }
67
+ export interface PtyShotRequest {
68
+ fixturePath: string;
69
+ outPath: string;
70
+ headed?: boolean;
71
+ cols?: number;
72
+ rows?: number;
73
+ scale?: number;
74
+ }
75
+ export interface BatchEntry {
76
+ fixture: string;
77
+ out: string;
78
+ }
79
+ export interface BatchManifest {
80
+ shots: BatchEntry[];
81
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@archastro/tui-shot",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic PNG screenshots of Ink fixtures and arbitrary PTY programs",
5
+ "keywords": [
6
+ "ink",
7
+ "pty",
8
+ "ratatui",
9
+ "terminal",
10
+ "screenshot",
11
+ "testing",
12
+ "tui"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ArchAstro/astroshots.git",
18
+ "directory": "packages/tui-shot"
19
+ },
20
+ "homepage": "https://github.com/ArchAstro/astroshots#readme",
21
+ "bugs": "https://github.com/ArchAstro/astroshots/issues",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "bin": {
25
+ "tui-shot": "bin/tui-shot.mjs"
26
+ },
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./types": {
34
+ "types": "./dist/types.d.ts",
35
+ "import": "./dist/types.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "dist",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.build.json",
45
+ "clean": "node scripts/clean.mjs",
46
+ "test": "npm run test:unit && npm run test:e2e",
47
+ "test:unit": "vitest run",
48
+ "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "prepack": "npm run clean && npm run build"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.14.0"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "provenance": true,
58
+ "registry": "https://registry.npmjs.org/"
59
+ },
60
+ "dependencies": {
61
+ "@xterm/headless": "^5.5.0",
62
+ "playwright": "1.61.1",
63
+ "tsx": "^4.20.0",
64
+ "yaml": "^2.8.0"
65
+ },
66
+ "optionalDependencies": {
67
+ "node-pty": "1.2.0-beta.14"
68
+ },
69
+ "peerDependencies": {
70
+ "ink": "^7.1.0",
71
+ "react": "^19.0.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "ink": {
75
+ "optional": true
76
+ },
77
+ "react": {
78
+ "optional": true
79
+ }
80
+ },
81
+ "devDependencies": {
82
+ "@types/node": "^22.0.0",
83
+ "@types/react": "^19.0.0",
84
+ "ink": "^7.1.0",
85
+ "react": "^19.2.0",
86
+ "sharp": "^0.35.3",
87
+ "typescript": "^5.9.0",
88
+ "vitest": "^4.1.0"
89
+ }
90
+ }