termcut 0.7.1 → 0.7.3
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 +1 -1
- package/package.json +1 -1
- package/scripts/probe-webview.ts +39 -0
- package/src/browser.ts +36 -10
- package/src/cli.ts +3 -3
- package/src/config.ts +1 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +1 -1
- package/src/recorder.ts +4 -2
- package/src/renderer/bundle.ts +2 -2
- package/src/renderer/png.ts +30 -7
- package/src/scriptgen.ts +43 -3
- package/src/testing.ts +1 -1
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Turn a terminal session into a video. Record it live or script it in TypeScript;
|
|
|
10
10
|
bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Standalone binaries for macOS, Linux and Windows: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't. Linux
|
|
13
|
+
Standalone binaries for macOS, Linux and Windows (all tested in CI): [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't. Linux and Windows render pixels through Chrome/Chromium.
|
|
14
14
|
|
|
15
15
|
## Use
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Diagnostic: how does Bun.WebView behave on this machine? Prints backend, navigation, evaluate and screenshot
|
|
2
|
+
// timings for a local page. `bun scripts/probe-webview.ts` (TCUT_DEBUG_CHROME=1 shows Chrome's stderr).
|
|
3
|
+
import { createWebView, webViewBackend } from "../src/renderer/view";
|
|
4
|
+
|
|
5
|
+
const t0 = performance.now();
|
|
6
|
+
const at = () => `${((performance.now() - t0) / 1000).toFixed(3)}s`;
|
|
7
|
+
const log = (m: string) => console.log(`${at()} ${m}`);
|
|
8
|
+
const timed = async <T>(label: string, p: Promise<T>, ms = 8000): Promise<T | undefined> => {
|
|
9
|
+
try {
|
|
10
|
+
const v = await Promise.race([p, Bun.sleep(ms).then(() => Promise.reject(new Error(`timeout ${ms}ms`)))]);
|
|
11
|
+
log(`${label}: ok ${JSON.stringify(v)?.slice(0, 80) ?? ""}`);
|
|
12
|
+
return v;
|
|
13
|
+
} catch (cause) {
|
|
14
|
+
log(`${label}: FAILED ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html><body><h1 id=t>first</h1><button onclick=\"t.textContent='clicked'\">go</button></body></html>", { headers: { "content-type": "text/html" } }) });
|
|
20
|
+
const url = `http://127.0.0.1:${server.port}/`;
|
|
21
|
+
log(`platform ${process.platform} backend ${JSON.stringify(webViewBackend())}`);
|
|
22
|
+
const view = createWebView({ width: 320, height: 240 });
|
|
23
|
+
log(`view created; url=${JSON.stringify(view.url)} loading=${view.loading}`);
|
|
24
|
+
const nav = view.navigate(url).then(() => "ok", (cause: unknown) => `rejected: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
25
|
+
for (let i = 0; i < 6; i++) {
|
|
26
|
+
await Bun.sleep(250);
|
|
27
|
+
log(`tick ${i}: url=${JSON.stringify(view.url)} loading=${view.loading}`);
|
|
28
|
+
}
|
|
29
|
+
await timed("navigate", nav, 5000);
|
|
30
|
+
await timed("evaluate readyState", view.evaluate("document.readyState"));
|
|
31
|
+
await timed("evaluate innerText", view.evaluate("document.body ? document.body.innerText : ''"));
|
|
32
|
+
const shot = await timed("screenshot", view.screenshot({ encoding: "buffer" }));
|
|
33
|
+
log(`screenshot bytes: ${shot ? (shot as Uint8Array).length : "none"}`);
|
|
34
|
+
await timed("click", view.click("button"), 5000);
|
|
35
|
+
await timed("evaluate after click", view.evaluate("document.body.innerText"));
|
|
36
|
+
await timed("screenshot 2", view.screenshot({ encoding: "buffer" }));
|
|
37
|
+
log(`final url=${JSON.stringify(view.url)} loading=${view.loading}`);
|
|
38
|
+
view.close();
|
|
39
|
+
server.stop(true);
|
package/src/browser.ts
CHANGED
|
@@ -48,6 +48,18 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
48
48
|
// Sampling starts once a page has loaded: headless Chrome never resolves a screenshot of the initial blank
|
|
49
49
|
// view, and a hung screenshot blocks every later command on that view.
|
|
50
50
|
let loaded = false;
|
|
51
|
+
/** Extra time allowed while the browser has never answered: a cold Chrome on a fresh Windows runner can take 20–40 s. */
|
|
52
|
+
const STARTUP_GRACE = 45_000;
|
|
53
|
+
const startedAt = performance.now();
|
|
54
|
+
let startupNoted = false;
|
|
55
|
+
const startupDeadline = (deadline: number): number => {
|
|
56
|
+
if (loaded) return deadline;
|
|
57
|
+
if (!startupNoted && performance.now() > startedAt + 5000) {
|
|
58
|
+
startupNoted = true;
|
|
59
|
+
log("waiting for the browser to start…");
|
|
60
|
+
}
|
|
61
|
+
return Math.max(deadline, startedAt + STARTUP_GRACE);
|
|
62
|
+
};
|
|
51
63
|
// A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
|
|
52
64
|
let chain: Promise<unknown> = Promise.resolve();
|
|
53
65
|
const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
|
|
@@ -58,7 +70,13 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
58
70
|
);
|
|
59
71
|
return next;
|
|
60
72
|
};
|
|
61
|
-
|
|
73
|
+
// A page that answers an evaluate is a page worth sampling (view.loading is not reliable on every backend).
|
|
74
|
+
const evaluate = (js: string, ms: number, label: string): Promise<unknown> =>
|
|
75
|
+
serial(async () => {
|
|
76
|
+
const result = await within(view.evaluate(js), ms, label);
|
|
77
|
+
loaded = true;
|
|
78
|
+
return result;
|
|
79
|
+
});
|
|
62
80
|
// One screenshot at a time: the periodic sampler and the event-driven samples (after waitFor, at stop) share it.
|
|
63
81
|
let sampling: Promise<void> | null = null;
|
|
64
82
|
const sampleOnce = (): Promise<void> => {
|
|
@@ -79,6 +97,11 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
79
97
|
})();
|
|
80
98
|
return sampling;
|
|
81
99
|
};
|
|
100
|
+
/** A sample taken after this moment: waits out any in-flight screenshot (seconds on a cold Chrome), then takes a fresh one. */
|
|
101
|
+
const sampleNow = async (): Promise<void> => {
|
|
102
|
+
if (sampling) await sampling;
|
|
103
|
+
await sampleOnce();
|
|
104
|
+
};
|
|
82
105
|
const sampler = (async () => {
|
|
83
106
|
while (running) {
|
|
84
107
|
await sampleOnce();
|
|
@@ -114,16 +137,16 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
114
137
|
}
|
|
115
138
|
if (outcome === "failed") navigation = null;
|
|
116
139
|
if (!running) return;
|
|
117
|
-
|
|
118
|
-
//
|
|
140
|
+
// No evaluate() here: the view's own loading flag and URL say whether the page arrived (some backends keep
|
|
141
|
+
// the navigate() promise pending long after the page is up, and may leave view.url empty).
|
|
119
142
|
const href = (view.url ?? "").replace(/\/$/, "");
|
|
120
|
-
if (
|
|
143
|
+
if (outcome === "tick" && (href.startsWith(target) || (!view.loading && href === ""))) {
|
|
121
144
|
currentUrl = url;
|
|
122
145
|
loaded = true;
|
|
123
146
|
return;
|
|
124
147
|
}
|
|
125
|
-
if (performance.now() > deadline) {
|
|
126
|
-
throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"},
|
|
148
|
+
if (performance.now() > startupDeadline(deadline)) {
|
|
149
|
+
throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, loading: ${view.loading}`);
|
|
127
150
|
}
|
|
128
151
|
await Bun.sleep(250);
|
|
129
152
|
}
|
|
@@ -143,10 +166,10 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
143
166
|
const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
|
|
144
167
|
if (regex.test(text)) {
|
|
145
168
|
loaded = true; // a page that answers is a page worth sampling, even if navigate() has not settled yet
|
|
146
|
-
await
|
|
169
|
+
await sampleNow(); // the frame the script waited for, captured the moment it appeared
|
|
147
170
|
return;
|
|
148
171
|
}
|
|
149
|
-
if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
|
|
172
|
+
if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
|
|
150
173
|
await Bun.sleep(150);
|
|
151
174
|
}
|
|
152
175
|
},
|
|
@@ -163,9 +186,12 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
163
186
|
reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
|
|
164
187
|
evaluate: (js) => evaluate(js, 10000, "evaluate"),
|
|
165
188
|
async stop() {
|
|
189
|
+
// A session shorter than the browser's start-up (cold Chrome on CI) should still show the page once.
|
|
190
|
+
if (frames.length === 0 && initialLoad) await Promise.race([initialLoad, Bun.sleep(Math.max(0, startupDeadline(0) - performance.now()))]);
|
|
191
|
+
if (!loaded && bcfg.url) await evaluate("document.readyState", 2000, "probe").catch(() => undefined); // marks loaded if the page answers
|
|
166
192
|
running = false;
|
|
167
193
|
await sampler;
|
|
168
|
-
await
|
|
194
|
+
await sampleNow(); // final state of the page
|
|
169
195
|
try {
|
|
170
196
|
view.close();
|
|
171
197
|
} catch {
|
|
@@ -174,6 +200,6 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
174
200
|
},
|
|
175
201
|
};
|
|
176
202
|
|
|
177
|
-
|
|
203
|
+
const initialLoad = bcfg.url ? goto(bcfg.url).catch((err) => log(String(err))) : null;
|
|
178
204
|
return capture;
|
|
179
205
|
}
|
package/src/cli.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { renderOutputs } from "./render";
|
|
|
15
15
|
import { generateScript } from "./scriptgen";
|
|
16
16
|
import { runScriptTests, type TestSummary } from "./testing";
|
|
17
17
|
import { findThemes, themeNames } from "./themes";
|
|
18
|
-
import type { BrowserConfig, ClipSelection, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
18
|
+
import type { BrowserConfig, ClipSelection, CoreName, Recording, ResolvedConfig, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
19
19
|
import { Video, attachBrowserFrames, castConfig, isVideo, renderCast } from "./video";
|
|
20
20
|
|
|
21
21
|
// Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
|
|
@@ -524,13 +524,13 @@ async function main(): Promise<void> {
|
|
|
524
524
|
const castOut = values.cast ?? (joining ? path.join(path.dirname(rest[0]!), "concat.cast") : rest[0]!.replace(/\.cast$/, "") + "-cut.cast");
|
|
525
525
|
const overrides = overridesFromFlags();
|
|
526
526
|
delete overrides.cast;
|
|
527
|
-
const parts: Array<{ rec:
|
|
527
|
+
const parts: Array<{ rec: Recording; config: ResolvedConfig }> = [];
|
|
528
528
|
for (const [i, file] of rest.entries()) {
|
|
529
529
|
const rec = await readCast(file);
|
|
530
530
|
const config = applyOverrides(castConfig(rec, file, values.output), overrides);
|
|
531
531
|
parts.push({ rec: await rebaseBrowserFrames(rec, file, castOut, joining ? `${i}-` : ""), config });
|
|
532
532
|
}
|
|
533
|
-
let out:
|
|
533
|
+
let out: Recording;
|
|
534
534
|
if (joining) {
|
|
535
535
|
out = concatRecordings(parts, { gap: seconds("gap") ?? 0 });
|
|
536
536
|
} else {
|
package/src/config.ts
CHANGED
package/src/errors.ts
CHANGED
|
@@ -7,6 +7,16 @@ export class WaitTimeoutError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** A program named in `requires` is not on the PATH — raised before the shell is even started. */
|
|
11
|
+
export class MissingRequirementError extends Error {
|
|
12
|
+
constructor(readonly missing: string[]) {
|
|
13
|
+
super(
|
|
14
|
+
`${missing.length === 1 ? `\`${missing[0]}\` is` : `${missing.map((m) => `\`${m}\``).join(", ")} are`} not on the PATH. Install ${missing.length === 1 ? "it" : "them"}, or remove ${missing.length === 1 ? "it" : "them"} from \`requires\`.`,
|
|
15
|
+
);
|
|
16
|
+
this.name = "MissingRequirementError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export class ExpectationError extends Error {
|
|
11
21
|
constructor(what: string, screen: string) {
|
|
12
22
|
super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
|
package/src/index.ts
CHANGED
|
@@ -26,5 +26,5 @@ export type { DiffOptions, DiffResult } from "./diff";
|
|
|
26
26
|
export { keyLabels, keyChips } from "./keylabels";
|
|
27
27
|
export { startBrowserCapture } from "./browser";
|
|
28
28
|
export { resolveConfig } from "./config";
|
|
29
|
-
export { WaitTimeoutError, ExpectationError } from "./recorder";
|
|
29
|
+
export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./recorder";
|
|
30
30
|
export type * from "./types";
|
package/src/recorder.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { MarkdownRenderer } from "@wterm/markdown";
|
|
|
2
2
|
import { startBrowserCapture, type BrowserCapture } from "./browser";
|
|
3
3
|
import { MARKER } from "./cast";
|
|
4
4
|
import { toMs } from "./duration";
|
|
5
|
-
import { ExpectationError, WaitTimeoutError } from "./errors";
|
|
5
|
+
import { ExpectationError, MissingRequirementError, WaitTimeoutError } from "./errors";
|
|
6
6
|
import { altSequence, ctrlSequence, keySequence, shiftSequence, wheelSequence } from "./keys";
|
|
7
7
|
import { Screen } from "./screen";
|
|
8
8
|
import type {
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
WaitOptions,
|
|
21
21
|
} from "./types";
|
|
22
22
|
|
|
23
|
-
export { WaitTimeoutError, ExpectationError } from "./errors";
|
|
23
|
+
export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./errors";
|
|
24
24
|
|
|
25
25
|
/** Deterministic PRNG (mulberry32) so typing jitter is reproducible. */
|
|
26
26
|
function mulberry32(seed: number): () => number {
|
|
@@ -103,6 +103,8 @@ export function defaultLang(): string {
|
|
|
103
103
|
/** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
|
|
104
104
|
export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
|
|
105
105
|
const log = opts.log ?? (() => {});
|
|
106
|
+
const missing = config.requires.filter((name) => Bun.which(name) === null);
|
|
107
|
+
if (missing.length > 0) throw new MissingRequirementError(missing);
|
|
106
108
|
const events: CastEvent[] = [];
|
|
107
109
|
const screen = await Screen.create(config.cols, config.rows, { core: config.core });
|
|
108
110
|
const fast = opts.fast === true;
|
package/src/renderer/bundle.ts
CHANGED
|
@@ -43,8 +43,8 @@ async function embeddedAssets(): Promise<PageAssets | null> {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** True inside a `bun build --compile` binary
|
|
47
|
-
const isCompiled =
|
|
46
|
+
/** True inside a `bun build --compile` binary (sources then live on a virtual filesystem: /$bunfs, or B:\~BUN on Windows). */
|
|
47
|
+
const isCompiled = Bun.isStandaloneExecutable;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Prebuilt assets in the compiled binary; otherwise built at runtime with Bun.build so edits to the page entries
|
package/src/renderer/png.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A minimal PNG codec for the renderer. Bun.WebView screenshots come in as opaque PNGs and Bun.Image has no raw
|
|
2
2
|
// pixel access, so transparent output needs its own decode → matte → encode step. 8-bit RGB/RGBA, non-interlaced.
|
|
3
|
-
//
|
|
4
|
-
|
|
3
|
+
// PNG streams are zlib-framed; Bun's inflateSync reads that with windowBits 15, and deflateSync's raw output gets the
|
|
4
|
+
// two-byte header and Adler-32 trailer added here (Bun only — no node:zlib).
|
|
5
5
|
|
|
6
6
|
/** Straight (non-premultiplied) RGBA pixels, row-major. */
|
|
7
7
|
export interface RgbaImage {
|
|
@@ -16,8 +16,31 @@ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
|
16
16
|
|
|
17
17
|
const readU32 = (b: Uint8Array, o: number): number => ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0;
|
|
18
18
|
|
|
19
|
-
function
|
|
20
|
-
|
|
19
|
+
function adler32(bytes: Uint8Array): number {
|
|
20
|
+
let a = 1;
|
|
21
|
+
let b = 0;
|
|
22
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
23
|
+
a = (a + bytes[i]!) % 65521;
|
|
24
|
+
b = (b + a) % 65521;
|
|
25
|
+
}
|
|
26
|
+
return ((b << 16) | a) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Raw DEFLATE from Bun.deflateSync wrapped as a zlib stream (RFC 1950): CMF/FLG header + data + Adler-32. */
|
|
30
|
+
function zlibDeflate(raw: Uint8Array<ArrayBuffer>, level: 1 | 6 = 1): Uint8Array {
|
|
31
|
+
const body = Bun.deflateSync(raw, { level });
|
|
32
|
+
const out = new Uint8Array(2 + body.length + 4);
|
|
33
|
+
out[0] = 0x78;
|
|
34
|
+
out[1] = level === 1 ? 0x01 : 0x9c;
|
|
35
|
+
out.set(body, 2);
|
|
36
|
+
new DataView(out.buffer).setUint32(2 + body.length, adler32(raw));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const zlibInflate = (data: Uint8Array<ArrayBuffer>): Uint8Array => Bun.inflateSync(data, { windowBits: 15 });
|
|
41
|
+
|
|
42
|
+
function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
43
|
+
const out = new Uint8Array(new ArrayBuffer(parts.reduce((n, p) => n + p.length, 0)));
|
|
21
44
|
let o = 0;
|
|
22
45
|
for (const p of parts) {
|
|
23
46
|
out.set(p, o);
|
|
@@ -83,7 +106,7 @@ export function decodePng(png: Uint8Array): RgbaImage {
|
|
|
83
106
|
}
|
|
84
107
|
const bpp = colorType === 6 ? 4 : 3;
|
|
85
108
|
const stride = width * bpp;
|
|
86
|
-
const raw =
|
|
109
|
+
const raw = zlibInflate(concat(idat));
|
|
87
110
|
const out = new Uint8Array(width * height * 4);
|
|
88
111
|
let prev = new Uint8Array(stride);
|
|
89
112
|
let row = new Uint8Array(stride);
|
|
@@ -133,7 +156,7 @@ function chunk(type: string, data: Uint8Array): Uint8Array {
|
|
|
133
156
|
/** Encode straight RGBA as an 8-bit PNG (filter 0; speed matters more than size — ffmpeg re-encodes anyway). */
|
|
134
157
|
export function encodePng(img: RgbaImage): Uint8Array {
|
|
135
158
|
const stride = img.width * 4;
|
|
136
|
-
const raw = new Uint8Array((stride + 1) * img.height);
|
|
159
|
+
const raw = new Uint8Array(new ArrayBuffer((stride + 1) * img.height));
|
|
137
160
|
for (let y = 0; y < img.height; y++) {
|
|
138
161
|
raw[y * (stride + 1)] = 0;
|
|
139
162
|
raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
@@ -144,7 +167,7 @@ export function encodePng(img: RgbaImage): Uint8Array {
|
|
|
144
167
|
v.setUint32(4, img.height);
|
|
145
168
|
ihdr[8] = 8; // bit depth
|
|
146
169
|
ihdr[9] = 6; // RGBA
|
|
147
|
-
return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT",
|
|
170
|
+
return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibDeflate(raw)), chunk("IEND", new Uint8Array(0))]);
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
export function parseHex(color: string): Rgb {
|
package/src/scriptgen.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { keySequence } from "./keys";
|
|
2
3
|
import type { Recording } from "./types";
|
|
3
4
|
|
|
4
5
|
export interface ScriptGenOptions {
|
|
@@ -20,7 +21,9 @@ type Op =
|
|
|
20
21
|
| { kind: "key"; name: string; times: number }
|
|
21
22
|
| { kind: "ctrl"; letter: string; times: number }
|
|
22
23
|
| { kind: "alt"; key: string; times: number }
|
|
23
|
-
| { kind: "
|
|
24
|
+
| { kind: "fkey"; name: string; times: number }
|
|
25
|
+
| { kind: "shift"; key: string; times: number }
|
|
26
|
+
| { kind: "raw"; data: string; comment?: string }
|
|
24
27
|
| { kind: "sleep"; ms: number };
|
|
25
28
|
|
|
26
29
|
const NAMED = new Map<string, string>([
|
|
@@ -46,6 +49,35 @@ const NAMED = new Map<string, string>([
|
|
|
46
49
|
["\x1b[6~", "pageDown"],
|
|
47
50
|
]);
|
|
48
51
|
|
|
52
|
+
/** F-keys by the bytes a terminal sends (`ESC O P` … `ESC [24~`), so they replay as `t.key("f5")`: one write, never ESC + key. */
|
|
53
|
+
const FKEYS = new Map(Array.from({ length: 12 }, (_, i) => [keySequence(`f${i + 1}` as `f${1}`), `f${i + 1}`] as const));
|
|
54
|
+
|
|
55
|
+
const MODIFIER_NAMES = new Map([
|
|
56
|
+
["2", "shift"],
|
|
57
|
+
["3", "alt"],
|
|
58
|
+
["5", "ctrl"],
|
|
59
|
+
["6", "ctrl+shift"],
|
|
60
|
+
["7", "ctrl+alt"],
|
|
61
|
+
]);
|
|
62
|
+
const MODIFIED_KEYS = new Map([
|
|
63
|
+
["A", "up"],
|
|
64
|
+
["B", "down"],
|
|
65
|
+
["C", "right"],
|
|
66
|
+
["D", "left"],
|
|
67
|
+
["H", "home"],
|
|
68
|
+
["F", "end"],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** `ESC [1;3D` → { modifier: "alt", key: "left" } (xterm modifier encoding), or null. */
|
|
72
|
+
function modifiedKey(token: string): { modifier: string; key: string } | null {
|
|
73
|
+
if (!token.startsWith("\x1b[1;")) return null;
|
|
74
|
+
const m = /^(\d)([A-DHF])$/.exec(token.slice(4));
|
|
75
|
+
if (!m) return null;
|
|
76
|
+
const modifier = MODIFIER_NAMES.get(m[1]!);
|
|
77
|
+
const key = MODIFIED_KEYS.get(m[2]!);
|
|
78
|
+
return modifier && key ? { modifier, key } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
/** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
|
|
50
82
|
export function tokenize(input: string): string[] {
|
|
51
83
|
const tokens: string[] = [];
|
|
@@ -160,8 +192,12 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
|
|
|
160
192
|
pushKey({ kind: "ctrl", letter, times: 1 });
|
|
161
193
|
} else if (token.length === 2 && token[0] === "\x1b") {
|
|
162
194
|
pushKey({ kind: "alt", key: token[1]!, times: 1 });
|
|
195
|
+
} else if (FKEYS.has(token)) {
|
|
196
|
+
pushKey({ kind: "fkey", name: FKEYS.get(token)!, times: 1 });
|
|
163
197
|
} else {
|
|
164
|
-
|
|
198
|
+
const mod = modifiedKey(token);
|
|
199
|
+
if (mod?.modifier === "shift") pushKey({ kind: "shift", key: mod.key, times: 1 });
|
|
200
|
+
else ops.push({ kind: "raw", data: token, ...(mod && { comment: `${mod.modifier}+${mod.key}` }) });
|
|
165
201
|
}
|
|
166
202
|
}
|
|
167
203
|
}
|
|
@@ -186,8 +222,12 @@ function opToLine(op: Op): string {
|
|
|
186
222
|
return op.times > 1 ? `await t.ctrl(${q(op.letter)}, ${op.times});` : `await t.ctrl(${q(op.letter)});`;
|
|
187
223
|
case "alt":
|
|
188
224
|
return op.times > 1 ? `await t.alt(${q(op.key)}, ${op.times});` : `await t.alt(${q(op.key)});`;
|
|
225
|
+
case "fkey":
|
|
226
|
+
return op.times > 1 ? `await t.key(${q(op.name)}, ${op.times});` : `await t.key(${q(op.name)});`;
|
|
227
|
+
case "shift":
|
|
228
|
+
return op.times > 1 ? `await t.shift(${q(op.key)}, ${op.times});` : `await t.shift(${q(op.key)});`;
|
|
189
229
|
case "raw":
|
|
190
|
-
return `await t.raw(${q(op.data)})
|
|
230
|
+
return `await t.raw(${q(op.data)});${op.comment ? ` // ${op.comment}` : ""}`;
|
|
191
231
|
case "sleep":
|
|
192
232
|
return `await t.sleep(${formatMs(op.ms)});`;
|
|
193
233
|
}
|
package/src/testing.ts
CHANGED
|
@@ -45,7 +45,7 @@ export async function runScriptTests(inputs: string[], log: (line: string) => vo
|
|
|
45
45
|
const results: TestResult[] = [];
|
|
46
46
|
log(`TAP version 14\n1..${files.length}`);
|
|
47
47
|
for (const [index, file] of files.entries()) {
|
|
48
|
-
const rel = path.relative(process.cwd(), file);
|
|
48
|
+
const rel = path.relative(process.cwd(), file).split(path.sep).join("/"); // stable TAP output on every platform
|
|
49
49
|
const started = performance.now();
|
|
50
50
|
let error: string | undefined;
|
|
51
51
|
try {
|
package/src/types.ts
CHANGED
|
@@ -205,6 +205,8 @@ export interface VideoConfig {
|
|
|
205
205
|
core?: CoreName;
|
|
206
206
|
/** Reuse the existing cast when the script and record config are unchanged. Default true. */
|
|
207
207
|
cache?: boolean;
|
|
208
|
+
/** Programs the script needs on the PATH (`["bun", "eza"]`). Checked before the shell starts; missing ones fail fast with a clear message. */
|
|
209
|
+
requires?: string[];
|
|
208
210
|
|
|
209
211
|
font?: FontConfig;
|
|
210
212
|
theme?: ThemeName | Theme;
|
|
@@ -257,6 +259,7 @@ export interface ResolvedConfig {
|
|
|
257
259
|
quantize: boolean;
|
|
258
260
|
core: CoreName;
|
|
259
261
|
cache: boolean;
|
|
262
|
+
requires: string[];
|
|
260
263
|
font: Required<FontConfig>;
|
|
261
264
|
theme: Theme;
|
|
262
265
|
cursor: Required<CursorConfig>;
|