astroshot 0.2.1
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/README.md +87 -0
- package/bin/astroshot.mjs +71 -0
- package/node_modules/@archastro/astroshot/LICENSE +21 -0
- package/node_modules/@archastro/astroshot/README.md +96 -0
- package/node_modules/@archastro/astroshot/bin/astroshot.mjs +276 -0
- package/node_modules/@archastro/astroshot/bin/demo.mjs +275 -0
- package/node_modules/@archastro/astroshot/bin/doctor.mjs +492 -0
- package/node_modules/@archastro/astroshot/bin/mac-preferences.mjs +346 -0
- package/node_modules/@archastro/astroshot/bin/templates.mjs +186 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/fixtures.json +42 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.webm +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/next-steps.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/welcome.png +0 -0
- package/node_modules/@archastro/astroshot/ink.d.ts +5 -0
- package/node_modules/@archastro/astroshot/ink.js +1 -0
- package/node_modules/@archastro/astroshot/package.json +79 -0
- package/node_modules/@archastro/astroshot/pty.d.ts +8 -0
- package/node_modules/@archastro/astroshot/pty.js +1 -0
- package/node_modules/@archastro/astroshot/react.d.ts +1 -0
- package/node_modules/@archastro/astroshot/react.js +1 -0
- package/node_modules/@archastro/astroshot/tui.d.ts +1 -0
- package/node_modules/@archastro/astroshot/tui.js +1 -0
- package/node_modules/@archastro/movie-harness/README.md +138 -0
- package/node_modules/@archastro/movie-harness/bin/astroshot-movie.mjs +5 -0
- package/node_modules/@archastro/movie-harness/dist/cli.d.ts +1 -0
- package/node_modules/@archastro/movie-harness/dist/cli.js +389 -0
- package/node_modules/@archastro/movie-harness/dist/encode.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/encode.js +184 -0
- package/node_modules/@archastro/movie-harness/dist/index.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/index.js +11 -0
- package/node_modules/@archastro/movie-harness/dist/paths.d.ts +9 -0
- package/node_modules/@archastro/movie-harness/dist/paths.js +57 -0
- package/node_modules/@archastro/movie-harness/dist/png.d.ts +8 -0
- package/node_modules/@archastro/movie-harness/dist/png.js +79 -0
- package/node_modules/@archastro/movie-harness/dist/session.d.ts +45 -0
- package/node_modules/@archastro/movie-harness/dist/session.js +170 -0
- package/node_modules/@archastro/movie-harness/dist/sink.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sink.js +113 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.d.ts +29 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.js +222 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.js +87 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.d.ts +110 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.js +505 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.d.ts +19 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.js +165 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.d.ts +15 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.js +330 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.d.ts +27 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.js +190 -0
- package/node_modules/@archastro/movie-harness/dist/types.d.ts +140 -0
- package/node_modules/@archastro/movie-harness/dist/types.js +1 -0
- package/node_modules/@archastro/movie-harness/native/macos/WindowTools.swift +164 -0
- package/node_modules/@archastro/movie-harness/package.json +67 -0
- package/node_modules/@archastro/react-shot/LICENSE +21 -0
- package/node_modules/@archastro/react-shot/README.md +181 -0
- package/node_modules/@archastro/react-shot/bin/react-shot.mjs +2 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js +34 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts +3 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.js +217 -0
- package/node_modules/@archastro/react-shot/dist/cli.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts +5 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.js +75 -0
- package/node_modules/@archastro/react-shot/dist/config.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts +13 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js +254 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.js +3 -0
- package/node_modules/@archastro/react-shot/dist/index.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts +9 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.js +23 -0
- package/node_modules/@archastro/react-shot/dist/meta.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.js +259 -0
- package/node_modules/@archastro/react-shot/dist/shot.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts +6 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js +49 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts +79 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.js +2 -0
- package/node_modules/@archastro/react-shot/dist/types.js.map +1 -0
- package/node_modules/@archastro/react-shot/host/index.html +22 -0
- package/node_modules/@archastro/react-shot/package.json +81 -0
- package/node_modules/@archastro/tui-shot/LICENSE +21 -0
- package/node_modules/@archastro/tui-shot/README.md +145 -0
- package/node_modules/@archastro/tui-shot/bin/tui-shot.mjs +23 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.js +33 -0
- package/node_modules/@archastro/tui-shot/dist/cli.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/cli.js +224 -0
- package/node_modules/@archastro/tui-shot/dist/index.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/index.js +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.d.ts +1 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.js +50 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.js +483 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.d.ts +10 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.js +87 -0
- package/node_modules/@archastro/tui-shot/dist/shot.d.ts +24 -0
- package/node_modules/@archastro/tui-shot/dist/shot.js +250 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.d.ts +14 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.js +144 -0
- package/node_modules/@archastro/tui-shot/dist/types.d.ts +81 -0
- package/node_modules/@archastro/tui-shot/dist/types.js +1 -0
- package/node_modules/@archastro/tui-shot/package.json +90 -0
- package/package.json +68 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
import { captureTerminalHtml, queueTerminalShot, validPositive, } from "./shot.js";
|
|
8
|
+
import { createHeadlessTerminal, terminalPlainText, terminalToHtml, writeTerminal, } from "./terminal-html.js";
|
|
9
|
+
const KEYSTROKES = {
|
|
10
|
+
enter: "\r",
|
|
11
|
+
up: "\x1b[A",
|
|
12
|
+
down: "\x1b[B",
|
|
13
|
+
right: "\x1b[C",
|
|
14
|
+
left: "\x1b[D",
|
|
15
|
+
tab: "\t",
|
|
16
|
+
escape: "\x1b",
|
|
17
|
+
backspace: "\x7f",
|
|
18
|
+
space: " ",
|
|
19
|
+
"ctrl-c": "\x03",
|
|
20
|
+
"ctrl-d": "\x04",
|
|
21
|
+
};
|
|
22
|
+
function fixtureError(fixturePath, detail) {
|
|
23
|
+
return new Error(`Invalid PTY fixture ${fixturePath}: ${detail}`);
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function validateAction(value, fixturePath, index) {
|
|
29
|
+
if (!isRecord(value)) {
|
|
30
|
+
throw fixtureError(fixturePath, `actions[${index}] must be an object`);
|
|
31
|
+
}
|
|
32
|
+
const operators = [
|
|
33
|
+
"waitFor",
|
|
34
|
+
"waitForExit",
|
|
35
|
+
"key",
|
|
36
|
+
"text",
|
|
37
|
+
"pauseMs",
|
|
38
|
+
].filter((name) => value[name] !== undefined);
|
|
39
|
+
if (operators.length !== 1) {
|
|
40
|
+
throw fixtureError(fixturePath, `actions[${index}] must set exactly one of waitFor, waitForExit, key, text, or pauseMs`);
|
|
41
|
+
}
|
|
42
|
+
const validatedTimeout = () => {
|
|
43
|
+
if (value.timeoutMs !== undefined &&
|
|
44
|
+
(typeof value.timeoutMs !== "number" ||
|
|
45
|
+
!Number.isFinite(value.timeoutMs) ||
|
|
46
|
+
value.timeoutMs <= 0)) {
|
|
47
|
+
throw fixtureError(fixturePath, `actions[${index}].timeoutMs must be a positive number`);
|
|
48
|
+
}
|
|
49
|
+
return value.timeoutMs;
|
|
50
|
+
};
|
|
51
|
+
if (operators[0] === "waitFor") {
|
|
52
|
+
if (typeof value.waitFor !== "string" || !value.waitFor) {
|
|
53
|
+
throw fixtureError(fixturePath, `actions[${index}].waitFor must be text`);
|
|
54
|
+
}
|
|
55
|
+
return { waitFor: value.waitFor, timeoutMs: validatedTimeout() };
|
|
56
|
+
}
|
|
57
|
+
if (operators[0] === "waitForExit") {
|
|
58
|
+
if (value.waitForExit !== true) {
|
|
59
|
+
throw fixtureError(fixturePath, `actions[${index}].waitForExit must be true`);
|
|
60
|
+
}
|
|
61
|
+
return { waitForExit: true, timeoutMs: validatedTimeout() };
|
|
62
|
+
}
|
|
63
|
+
if (operators[0] === "key") {
|
|
64
|
+
if (typeof value.key !== "string" ||
|
|
65
|
+
!(value.key.toLowerCase() in KEYSTROKES)) {
|
|
66
|
+
throw fixtureError(fixturePath, `actions[${index}].key must be one of ${Object.keys(KEYSTROKES).join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
return { key: value.key.toLowerCase() };
|
|
69
|
+
}
|
|
70
|
+
if (operators[0] === "text") {
|
|
71
|
+
if (typeof value.text !== "string") {
|
|
72
|
+
throw fixtureError(fixturePath, `actions[${index}].text must be a string`);
|
|
73
|
+
}
|
|
74
|
+
return { text: value.text };
|
|
75
|
+
}
|
|
76
|
+
if (typeof value.pauseMs !== "number" ||
|
|
77
|
+
!Number.isFinite(value.pauseMs) ||
|
|
78
|
+
value.pauseMs < 0) {
|
|
79
|
+
throw fixtureError(fixturePath, `actions[${index}].pauseMs must be a non-negative number`);
|
|
80
|
+
}
|
|
81
|
+
return { pauseMs: value.pauseMs };
|
|
82
|
+
}
|
|
83
|
+
function stringArray(value, fixturePath, field) {
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!Array.isArray(value) ||
|
|
87
|
+
value.some((entry) => typeof entry !== "string")) {
|
|
88
|
+
throw fixtureError(fixturePath, `${field} must be an array of strings`);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function loadPtyFixture(fixturePath) {
|
|
93
|
+
const absolute = path.resolve(fixturePath);
|
|
94
|
+
if (!fs.existsSync(absolute)) {
|
|
95
|
+
throw new Error(`Fixture not found: ${absolute}`);
|
|
96
|
+
}
|
|
97
|
+
const raw = fs.readFileSync(absolute, "utf8");
|
|
98
|
+
let value;
|
|
99
|
+
try {
|
|
100
|
+
value = path.extname(absolute).toLowerCase() === ".json"
|
|
101
|
+
? JSON.parse(raw)
|
|
102
|
+
: YAML.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw fixtureError(absolute, error instanceof Error ? error.message : String(error));
|
|
106
|
+
}
|
|
107
|
+
if (!isRecord(value)) {
|
|
108
|
+
throw fixtureError(absolute, "the document must be an object");
|
|
109
|
+
}
|
|
110
|
+
if (value.version !== 1) {
|
|
111
|
+
throw fixtureError(absolute, "version must be 1");
|
|
112
|
+
}
|
|
113
|
+
if (typeof value.command !== "string" || !value.command.trim()) {
|
|
114
|
+
throw fixtureError(absolute, "command must be a non-empty string");
|
|
115
|
+
}
|
|
116
|
+
const args = stringArray(value.args, absolute, "args");
|
|
117
|
+
const expectText = stringArray(value.expectText, absolute, "expectText");
|
|
118
|
+
if (value.cwd !== undefined && typeof value.cwd !== "string") {
|
|
119
|
+
throw fixtureError(absolute, "cwd must be a string");
|
|
120
|
+
}
|
|
121
|
+
if (value.allowNonZeroExit !== undefined &&
|
|
122
|
+
typeof value.allowNonZeroExit !== "boolean") {
|
|
123
|
+
throw fixtureError(absolute, "allowNonZeroExit must be a boolean");
|
|
124
|
+
}
|
|
125
|
+
for (const field of ["background", "foreground", "fontFamily"]) {
|
|
126
|
+
if (value[field] !== undefined && typeof value[field] !== "string") {
|
|
127
|
+
throw fixtureError(absolute, `${field} must be a string`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let env;
|
|
131
|
+
if (value.env !== undefined) {
|
|
132
|
+
if (!isRecord(value.env) ||
|
|
133
|
+
Object.values(value.env).some((entry) => typeof entry !== "string")) {
|
|
134
|
+
throw fixtureError(absolute, "env values must be strings");
|
|
135
|
+
}
|
|
136
|
+
env = value.env;
|
|
137
|
+
}
|
|
138
|
+
if (value.actions !== undefined && !Array.isArray(value.actions)) {
|
|
139
|
+
throw fixtureError(absolute, "actions must be an array");
|
|
140
|
+
}
|
|
141
|
+
const actions = value.actions?.map((action, index) => validateAction(action, absolute, index));
|
|
142
|
+
return {
|
|
143
|
+
...value,
|
|
144
|
+
command: value.command,
|
|
145
|
+
args,
|
|
146
|
+
cwd: value.cwd,
|
|
147
|
+
env,
|
|
148
|
+
actions,
|
|
149
|
+
expectText,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function milliseconds(value, fallback, name, maximum) {
|
|
153
|
+
const resolved = value ?? fallback;
|
|
154
|
+
if (!Number.isFinite(resolved) ||
|
|
155
|
+
resolved < 0 ||
|
|
156
|
+
resolved > maximum) {
|
|
157
|
+
throw new Error(`${name} must be between 0 and ${maximum}`);
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
function delay(durationMs) {
|
|
162
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
163
|
+
}
|
|
164
|
+
function resolvePtyCommand(command, cwd, env) {
|
|
165
|
+
const validateExecutable = (resolved) => {
|
|
166
|
+
if (process.platform === "win32" &&
|
|
167
|
+
[".bat", ".cmd"].includes(path.extname(resolved).toLowerCase())) {
|
|
168
|
+
throw new Error(`PTY command resolves to a Windows batch script, which requires a shell: ${resolved}. ` +
|
|
169
|
+
"Use the underlying .exe executable to keep capture shell-free.");
|
|
170
|
+
}
|
|
171
|
+
return resolved;
|
|
172
|
+
};
|
|
173
|
+
if (path.isAbsolute(command))
|
|
174
|
+
return validateExecutable(command);
|
|
175
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
176
|
+
return validateExecutable(path.resolve(cwd, command));
|
|
177
|
+
}
|
|
178
|
+
if (process.platform !== "win32")
|
|
179
|
+
return validateExecutable(command);
|
|
180
|
+
const pathValue = Object.entries(env).find(([name]) => name.toLowerCase() === "path")?.[1] ??
|
|
181
|
+
"";
|
|
182
|
+
const commandExtension = path.extname(command);
|
|
183
|
+
const extensions = commandExtension
|
|
184
|
+
? [""]
|
|
185
|
+
: (Object.entries(env).find(([name]) => name.toLowerCase() === "pathext")?.[1] ?? ".COM;.EXE")
|
|
186
|
+
.split(";")
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
189
|
+
const unquotedDirectory = directory.replace(/^"(.*)"$/, "$1");
|
|
190
|
+
if (!unquotedDirectory)
|
|
191
|
+
continue;
|
|
192
|
+
for (const extension of extensions) {
|
|
193
|
+
const candidate = path.join(unquotedDirectory, command + extension);
|
|
194
|
+
let isFile = false;
|
|
195
|
+
try {
|
|
196
|
+
isFile = fs.statSync(candidate).isFile();
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Continue through PATH candidates.
|
|
200
|
+
}
|
|
201
|
+
if (isFile)
|
|
202
|
+
return validateExecutable(candidate);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`PTY command was not found on PATH: ${command}`);
|
|
206
|
+
}
|
|
207
|
+
async function takeIsolatedPtyShot(request) {
|
|
208
|
+
const absoluteFixturePath = path.resolve(request.fixturePath);
|
|
209
|
+
const fixture = loadPtyFixture(absoluteFixturePath);
|
|
210
|
+
const cols = validPositive(request.cols ?? fixture.cols ?? 100, "cols", {
|
|
211
|
+
integer: true,
|
|
212
|
+
maximum: 1_000,
|
|
213
|
+
});
|
|
214
|
+
const rows = validPositive(request.rows ?? fixture.rows ?? 30, "rows", {
|
|
215
|
+
integer: true,
|
|
216
|
+
maximum: 1_000,
|
|
217
|
+
});
|
|
218
|
+
const scale = validPositive(request.scale ?? fixture.scale ?? 2, "scale", {
|
|
219
|
+
maximum: 4,
|
|
220
|
+
});
|
|
221
|
+
const timeoutMs = milliseconds(fixture.timeoutMs, 15_000, "timeoutMs", 120_000);
|
|
222
|
+
const settleMs = milliseconds(fixture.settleMs, 100, "settleMs", 10_000);
|
|
223
|
+
const background = fixture.background ?? "#090a12";
|
|
224
|
+
const foreground = fixture.foreground ?? "#e8e8f2";
|
|
225
|
+
const fontFamily = fixture.fontFamily ??
|
|
226
|
+
'"SFMono-Regular", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace';
|
|
227
|
+
const fontSize = validPositive(fixture.fontSize ?? 15, "fontSize", {
|
|
228
|
+
maximum: 200,
|
|
229
|
+
});
|
|
230
|
+
const lineHeight = validPositive(fixture.lineHeight ?? 1.32, "lineHeight", {
|
|
231
|
+
maximum: 10,
|
|
232
|
+
});
|
|
233
|
+
const padding = milliseconds(fixture.padding, 22, "padding", 1_000);
|
|
234
|
+
const borderRadius = milliseconds(fixture.borderRadius, 12, "borderRadius", 1_000);
|
|
235
|
+
const fixtureDirectory = path.dirname(absoluteFixturePath);
|
|
236
|
+
const cwd = path.resolve(fixtureDirectory, fixture.cwd ?? ".");
|
|
237
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
238
|
+
throw new Error(`PTY fixture cwd is not a directory: ${cwd}`);
|
|
239
|
+
}
|
|
240
|
+
const terminal = createHeadlessTerminal(cols, rows);
|
|
241
|
+
const childEnvironment = {
|
|
242
|
+
...process.env,
|
|
243
|
+
TERM: "xterm-256color",
|
|
244
|
+
COLORTERM: "truecolor",
|
|
245
|
+
...fixture.env,
|
|
246
|
+
};
|
|
247
|
+
const command = resolvePtyCommand(fixture.command, cwd, childEnvironment);
|
|
248
|
+
const useExitWrapper = process.platform === "win32" ||
|
|
249
|
+
process.env.ASTROSHOT_TEST_FORCE_PTY_EXIT_WRAPPER === "1";
|
|
250
|
+
const exitMarkerToken = useExitWrapper ? randomBytes(16).toString("hex") : "";
|
|
251
|
+
const exitMarkerPrefix = `\x1b]777;astroshot-exit-${exitMarkerToken}=`;
|
|
252
|
+
const exitStatusDirectory = useExitWrapper
|
|
253
|
+
? fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-pty-exit-"))
|
|
254
|
+
: null;
|
|
255
|
+
const exitStatusPath = exitStatusDirectory
|
|
256
|
+
? path.join(exitStatusDirectory, "status")
|
|
257
|
+
: null;
|
|
258
|
+
const spawnedCommand = useExitWrapper ? process.execPath : command;
|
|
259
|
+
const spawnedArgs = useExitWrapper
|
|
260
|
+
? [
|
|
261
|
+
fileURLToPath(new URL("./pty-exit-wrapper.js", import.meta.url)),
|
|
262
|
+
exitMarkerToken,
|
|
263
|
+
exitStatusPath,
|
|
264
|
+
command,
|
|
265
|
+
...(fixture.args ?? []),
|
|
266
|
+
]
|
|
267
|
+
: (fixture.args ?? []);
|
|
268
|
+
let writes = Promise.resolve();
|
|
269
|
+
let markerBuffer = "";
|
|
270
|
+
let wrappedExitCode = null;
|
|
271
|
+
let child = null;
|
|
272
|
+
let exited = null;
|
|
273
|
+
let resolveExit = null;
|
|
274
|
+
const exitPromise = new Promise((resolve) => {
|
|
275
|
+
resolveExit = resolve;
|
|
276
|
+
});
|
|
277
|
+
const deadline = Date.now() + timeoutMs;
|
|
278
|
+
const screen = () => terminalPlainText(terminal, rows);
|
|
279
|
+
const remaining = () => Math.max(0, deadline - Date.now());
|
|
280
|
+
const exitState = () => exited;
|
|
281
|
+
const refreshWrappedExitCode = () => {
|
|
282
|
+
if (!exitStatusPath || wrappedExitCode !== null)
|
|
283
|
+
return wrappedExitCode;
|
|
284
|
+
let value;
|
|
285
|
+
try {
|
|
286
|
+
value = fs.readFileSync(exitStatusPath, "utf8");
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error.code === "ENOENT") {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
if (!/^\d+$/.test(value)) {
|
|
295
|
+
throw new Error("The PTY status wrapper reported an invalid exit code.");
|
|
296
|
+
}
|
|
297
|
+
wrappedExitCode = Number(value);
|
|
298
|
+
return wrappedExitCode;
|
|
299
|
+
};
|
|
300
|
+
async function waitForText(text, actionTimeout) {
|
|
301
|
+
const actionDeadline = Math.min(deadline, Date.now() +
|
|
302
|
+
milliseconds(actionTimeout, timeoutMs, "action timeoutMs", 120_000));
|
|
303
|
+
while (Date.now() <= actionDeadline) {
|
|
304
|
+
await writes;
|
|
305
|
+
if (screen().includes(text))
|
|
306
|
+
return;
|
|
307
|
+
if (exited)
|
|
308
|
+
break;
|
|
309
|
+
await delay(Math.min(20, Math.max(1, actionDeadline - Date.now())));
|
|
310
|
+
}
|
|
311
|
+
const exitDetail = exited
|
|
312
|
+
? ` The program exited with code ${exited.exitCode}.`
|
|
313
|
+
: "";
|
|
314
|
+
throw new Error(`Timed out waiting for ${JSON.stringify(text)}.${exitDetail} Visible frame:\n${screen().slice(0, 1_200)}`);
|
|
315
|
+
}
|
|
316
|
+
async function waitForProgramExit(actionTimeout) {
|
|
317
|
+
const waitStartedAt = Date.now();
|
|
318
|
+
const waitDuration = Math.min(Math.max(0, deadline - waitStartedAt), milliseconds(actionTimeout, timeoutMs, "action timeoutMs", 120_000));
|
|
319
|
+
const actionDeadline = waitStartedAt + waitDuration;
|
|
320
|
+
while (Date.now() <= actionDeadline) {
|
|
321
|
+
await writes;
|
|
322
|
+
if (useExitWrapper ? refreshWrappedExitCode() !== null : exited !== null) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await delay(Math.min(20, Math.max(1, actionDeadline - Date.now())));
|
|
326
|
+
}
|
|
327
|
+
if (useExitWrapper && exited) {
|
|
328
|
+
throw new Error("The PTY status wrapper exited without reporting the program exit code.");
|
|
329
|
+
}
|
|
330
|
+
throw new Error(`Timed out waiting for the PTY program to exit within ${waitDuration}ms. ` +
|
|
331
|
+
`Visible frame:\n${screen().slice(0, 1_200)}`);
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
let spawnPty;
|
|
335
|
+
try {
|
|
336
|
+
({ spawn: spawnPty } = await import("node-pty"));
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
throw new Error("PTY capture is unavailable because the optional node-pty native addon could not load on this platform. Reinstall @archastro/astroshot in a supported Node.js environment.", { cause: error });
|
|
340
|
+
}
|
|
341
|
+
child = spawnPty(spawnedCommand, spawnedArgs, {
|
|
342
|
+
name: "xterm-256color",
|
|
343
|
+
cols,
|
|
344
|
+
rows,
|
|
345
|
+
cwd,
|
|
346
|
+
env: childEnvironment,
|
|
347
|
+
});
|
|
348
|
+
child.onData((data) => {
|
|
349
|
+
if (useExitWrapper && wrappedExitCode === null) {
|
|
350
|
+
markerBuffer = (markerBuffer + data).slice(-4_096);
|
|
351
|
+
const markerStart = markerBuffer.lastIndexOf(exitMarkerPrefix);
|
|
352
|
+
if (markerStart !== -1) {
|
|
353
|
+
const valueStart = markerStart + exitMarkerPrefix.length;
|
|
354
|
+
const markerEnd = markerBuffer.indexOf("\x07", valueStart);
|
|
355
|
+
if (markerEnd !== -1) {
|
|
356
|
+
const value = markerBuffer.slice(valueStart, markerEnd);
|
|
357
|
+
if (/^\d+$/.test(value))
|
|
358
|
+
wrappedExitCode = Number(value);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
writes = writes.then(() => writeTerminal(terminal, data));
|
|
363
|
+
});
|
|
364
|
+
child.onExit((event) => {
|
|
365
|
+
exited = event;
|
|
366
|
+
resolveExit?.();
|
|
367
|
+
});
|
|
368
|
+
for (const action of fixture.actions ?? []) {
|
|
369
|
+
if (remaining() === 0) {
|
|
370
|
+
throw new Error(`PTY fixture exceeded timeoutMs ${timeoutMs}`);
|
|
371
|
+
}
|
|
372
|
+
if ("waitFor" in action) {
|
|
373
|
+
await waitForText(action.waitFor, action.timeoutMs);
|
|
374
|
+
}
|
|
375
|
+
else if ("waitForExit" in action) {
|
|
376
|
+
await waitForProgramExit(action.timeoutMs);
|
|
377
|
+
}
|
|
378
|
+
else if ("key" in action) {
|
|
379
|
+
child.write(KEYSTROKES[action.key]);
|
|
380
|
+
}
|
|
381
|
+
else if ("text" in action) {
|
|
382
|
+
child.write(action.text);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const pauseMs = Math.min(action.pauseMs, remaining());
|
|
386
|
+
await delay(pauseMs);
|
|
387
|
+
if (pauseMs < action.pauseMs) {
|
|
388
|
+
throw new Error(`PTY fixture exceeded timeoutMs ${timeoutMs}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (settleMs > remaining()) {
|
|
393
|
+
throw new Error(`PTY fixture exceeded timeoutMs ${timeoutMs}`);
|
|
394
|
+
}
|
|
395
|
+
await delay(settleMs);
|
|
396
|
+
await writes;
|
|
397
|
+
// Re-read after settle: the child may have exited during the settle
|
|
398
|
+
// window (status file or OSC marker arriving just after the last poll).
|
|
399
|
+
refreshWrappedExitCode();
|
|
400
|
+
// If ConPTY delivered onExit but the status bridge file is still racing
|
|
401
|
+
// the rename, give the wrapper a short fixed window before failing open.
|
|
402
|
+
if (useExitWrapper && exitState() && wrappedExitCode === null) {
|
|
403
|
+
const bridgeDeadline = Date.now() + 1_000;
|
|
404
|
+
while (Date.now() <= bridgeDeadline && wrappedExitCode === null) {
|
|
405
|
+
await delay(20);
|
|
406
|
+
refreshWrappedExitCode();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const plainFrame = screen();
|
|
410
|
+
const completed = exitState();
|
|
411
|
+
if (useExitWrapper && completed && wrappedExitCode === null) {
|
|
412
|
+
throw new Error("The PTY status wrapper exited without reporting the program exit code.");
|
|
413
|
+
}
|
|
414
|
+
const completedExitCode = wrappedExitCode ?? completed?.exitCode;
|
|
415
|
+
if (completedExitCode !== undefined &&
|
|
416
|
+
completedExitCode !== null &&
|
|
417
|
+
completedExitCode !== 0 &&
|
|
418
|
+
!fixture.allowNonZeroExit) {
|
|
419
|
+
throw new Error(`PTY program exited with code ${completedExitCode} before capture. ` +
|
|
420
|
+
"Set allowNonZeroExit: true only when documenting an intentional failure state. " +
|
|
421
|
+
`Visible frame:\n${plainFrame.slice(0, 1_200)}`);
|
|
422
|
+
}
|
|
423
|
+
for (const expected of fixture.expectText ?? []) {
|
|
424
|
+
if (!plainFrame.includes(expected)) {
|
|
425
|
+
throw new Error(`Fixture did not render expected text ${JSON.stringify(expected)}. ` +
|
|
426
|
+
`Visible frame:\n${plainFrame.slice(0, 1_200)}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const terminalRows = terminalToHtml(terminal, {
|
|
430
|
+
cols,
|
|
431
|
+
rows,
|
|
432
|
+
foreground,
|
|
433
|
+
background,
|
|
434
|
+
});
|
|
435
|
+
return await captureTerminalHtml({
|
|
436
|
+
terminalRows,
|
|
437
|
+
outPath: request.outPath,
|
|
438
|
+
headed: request.headed,
|
|
439
|
+
cols,
|
|
440
|
+
rows,
|
|
441
|
+
scale,
|
|
442
|
+
background,
|
|
443
|
+
foreground,
|
|
444
|
+
fontFamily,
|
|
445
|
+
fontSize,
|
|
446
|
+
lineHeight,
|
|
447
|
+
padding,
|
|
448
|
+
borderRadius,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
try {
|
|
453
|
+
if (child && !exited) {
|
|
454
|
+
try {
|
|
455
|
+
child.kill();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// The process may have exited between the state check and kill.
|
|
459
|
+
}
|
|
460
|
+
await Promise.race([exitPromise, delay(500)]);
|
|
461
|
+
if (!exited) {
|
|
462
|
+
try {
|
|
463
|
+
child.kill("SIGKILL");
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// ConPTY and already-exited Unix children can reject a second kill.
|
|
467
|
+
}
|
|
468
|
+
await Promise.race([exitPromise, delay(500)]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
await writes;
|
|
472
|
+
terminal.dispose();
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
if (exitStatusDirectory) {
|
|
476
|
+
fs.rmSync(exitStatusDirectory, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
export function takePtyShot(request) {
|
|
482
|
+
return queueTerminalShot(() => takeIsolatedPtyShot(request));
|
|
483
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { render as inkRender } from "ink";
|
|
2
|
+
import type { TuiShotFixture } from "./types.js";
|
|
3
|
+
export interface InkRuntime {
|
|
4
|
+
render: typeof inkRender;
|
|
5
|
+
chalk: {
|
|
6
|
+
level: number;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Render one complete ANSI frame from a deterministic Ink fixture. */
|
|
10
|
+
export declare function renderInkFrame(fixture: TuiShotFixture, cols: number, rows: number, runtime: InkRuntime): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
const CI_KEYS = [
|
|
3
|
+
"CI",
|
|
4
|
+
"CONTINUOUS_INTEGRATION",
|
|
5
|
+
"GITHUB_ACTIONS",
|
|
6
|
+
"BUILD_NUMBER",
|
|
7
|
+
"RUN_ID",
|
|
8
|
+
"GITLAB_CI",
|
|
9
|
+
"CIRCLECI",
|
|
10
|
+
"TRAVIS",
|
|
11
|
+
"BUILDKITE",
|
|
12
|
+
];
|
|
13
|
+
function fakeStdin() {
|
|
14
|
+
const stream = new EventEmitter();
|
|
15
|
+
stream.isTTY = true;
|
|
16
|
+
stream.setRawMode = () => stream;
|
|
17
|
+
stream.setEncoding = () => stream;
|
|
18
|
+
stream.resume = () => stream;
|
|
19
|
+
stream.pause = () => stream;
|
|
20
|
+
stream.ref = () => stream;
|
|
21
|
+
stream.unref = () => stream;
|
|
22
|
+
stream.read = () => null;
|
|
23
|
+
return stream;
|
|
24
|
+
}
|
|
25
|
+
function printableText(chunk) {
|
|
26
|
+
return chunk
|
|
27
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
28
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
29
|
+
.replace(/\x1b[@-_]/g, "");
|
|
30
|
+
}
|
|
31
|
+
/** Render one complete ANSI frame from a deterministic Ink fixture. */
|
|
32
|
+
export function renderInkFrame(fixture, cols, rows, runtime) {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
const stdout = {
|
|
35
|
+
columns: cols,
|
|
36
|
+
rows,
|
|
37
|
+
isTTY: true,
|
|
38
|
+
write: (chunk) => {
|
|
39
|
+
chunks.push(String(chunk));
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
on() { },
|
|
43
|
+
off() { },
|
|
44
|
+
once() { },
|
|
45
|
+
removeListener() { },
|
|
46
|
+
end() { },
|
|
47
|
+
};
|
|
48
|
+
const saved = new Map();
|
|
49
|
+
const chalkLevel = runtime.chalk.level;
|
|
50
|
+
for (const key of CI_KEYS) {
|
|
51
|
+
saved.set(key, process.env[key]);
|
|
52
|
+
delete process.env[key];
|
|
53
|
+
}
|
|
54
|
+
let frame = "";
|
|
55
|
+
try {
|
|
56
|
+
runtime.chalk.level = 3;
|
|
57
|
+
const instance = runtime.render(fixture.component, {
|
|
58
|
+
debug: true,
|
|
59
|
+
exitOnCtrlC: false,
|
|
60
|
+
patchConsole: false,
|
|
61
|
+
stdin: fakeStdin(),
|
|
62
|
+
stdout,
|
|
63
|
+
});
|
|
64
|
+
for (let index = chunks.length - 1; index >= 0; index--) {
|
|
65
|
+
const chunk = chunks[index];
|
|
66
|
+
if (printableText(chunk).trim().length > 0) {
|
|
67
|
+
frame = chunk;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
instance.unmount();
|
|
72
|
+
instance.cleanup();
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
runtime.chalk.level = chalkLevel;
|
|
76
|
+
for (const [key, value] of saved) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
delete process.env[key];
|
|
79
|
+
else
|
|
80
|
+
process.env[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!frame) {
|
|
84
|
+
throw new Error("Ink produced no printable frame. Check that the fixture renders visible content.");
|
|
85
|
+
}
|
|
86
|
+
return frame;
|
|
87
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TuiShotRequest } from "./types.js";
|
|
2
|
+
export declare function closeSharedBrowser(): Promise<void>;
|
|
3
|
+
export declare function validPositive(value: number, name: string, options: {
|
|
4
|
+
integer?: boolean;
|
|
5
|
+
maximum: number;
|
|
6
|
+
}): number;
|
|
7
|
+
export interface TerminalCaptureRequest {
|
|
8
|
+
terminalRows: string;
|
|
9
|
+
outPath: string;
|
|
10
|
+
headed?: boolean;
|
|
11
|
+
cols: number;
|
|
12
|
+
rows: number;
|
|
13
|
+
scale: number;
|
|
14
|
+
background: string;
|
|
15
|
+
foreground: string;
|
|
16
|
+
fontFamily: string;
|
|
17
|
+
fontSize: number;
|
|
18
|
+
lineHeight: number;
|
|
19
|
+
padding: number;
|
|
20
|
+
borderRadius: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function captureTerminalHtml(request: TerminalCaptureRequest): Promise<string>;
|
|
23
|
+
export declare function queueTerminalShot<T>(task: () => Promise<T>): Promise<T>;
|
|
24
|
+
export declare function takeTuiShot(request: TuiShotRequest): Promise<string>;
|