termcut 0.7.0 → 0.7.2
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 +72 -15
- package/src/recorder.ts +23 -1
- package/src/renderer/bundle.ts +2 -2
- package/src/renderer/view.ts +41 -0
- package/src/renderer/webview.ts +2 -5
- package/src/scriptgen.ts +43 -3
- package/src/testing.ts +1 -1
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: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
|
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WINDOW_BAR_HEIGHT, estimateCell } from "./config";
|
|
2
|
+
import { createWebView } from "./renderer/view";
|
|
2
3
|
import { toMs } from "./duration";
|
|
3
4
|
import { WaitTimeoutError } from "./errors";
|
|
4
5
|
import type { BrowserFrame, BrowserSession, ResolvedConfig } from "./types";
|
|
@@ -25,7 +26,7 @@ const toRegExp = (pattern: RegExp | string): RegExp =>
|
|
|
25
26
|
*/
|
|
26
27
|
export function startBrowserCapture(config: ResolvedConfig, stamp: () => number, log: (m: string) => void = () => {}): BrowserCapture {
|
|
27
28
|
if (!config.browser) throw new Error("startBrowserCapture needs config.browser");
|
|
28
|
-
|
|
29
|
+
|
|
29
30
|
const bcfg = config.browser;
|
|
30
31
|
|
|
31
32
|
// Default pane size: match the terminal window (estimated from the font metrics) unless given.
|
|
@@ -35,7 +36,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
35
36
|
const stacked = bcfg.position === "top" || bcfg.position === "bottom";
|
|
36
37
|
const paneW = stacked ? termFrameW : bcfg.width;
|
|
37
38
|
const paneH = bcfg.height || (stacked || bcfg.position === "overlay" ? 480 : termFrameH);
|
|
38
|
-
const view =
|
|
39
|
+
const view = createWebView({ width: paneW, height: paneH });
|
|
39
40
|
|
|
40
41
|
const frames: BrowserFrame[] = [];
|
|
41
42
|
const within = <T,>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
|
|
@@ -44,8 +45,35 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
44
45
|
let currentUrl = bcfg.url ?? "about:blank";
|
|
45
46
|
let lastHash = "";
|
|
46
47
|
let running = true;
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// Sampling starts once a page has loaded: headless Chrome never resolves a screenshot of the initial blank
|
|
49
|
+
// view, and a hung screenshot blocks every later command on that view.
|
|
50
|
+
let loaded = false;
|
|
51
|
+
/** Extra time allowed while the browser has never answered: a cold Chrome on a CI runner can take ~20 s to come up. */
|
|
52
|
+
const STARTUP_GRACE = 20_000;
|
|
53
|
+
const startedAt = performance.now();
|
|
54
|
+
const startupDeadline = (deadline: number): number => (loaded ? deadline : Math.max(deadline, startedAt + STARTUP_GRACE));
|
|
55
|
+
// A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
|
|
56
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
57
|
+
const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
|
|
58
|
+
const next = chain.then(fn, fn);
|
|
59
|
+
chain = next.then(
|
|
60
|
+
() => undefined,
|
|
61
|
+
() => undefined,
|
|
62
|
+
);
|
|
63
|
+
return next;
|
|
64
|
+
};
|
|
65
|
+
// A page that answers an evaluate is a page worth sampling (view.loading is not reliable on every backend).
|
|
66
|
+
const evaluate = (js: string, ms: number, label: string): Promise<unknown> =>
|
|
67
|
+
serial(async () => {
|
|
68
|
+
const result = await within(view.evaluate(js), ms, label);
|
|
69
|
+
loaded = true;
|
|
70
|
+
return result;
|
|
71
|
+
});
|
|
72
|
+
// One screenshot at a time: the periodic sampler and the event-driven samples (after waitFor, at stop) share it.
|
|
73
|
+
let sampling: Promise<void> | null = null;
|
|
74
|
+
const sampleOnce = (): Promise<void> => {
|
|
75
|
+
if (!loaded) return Promise.resolve();
|
|
76
|
+
sampling ??= (async () => {
|
|
49
77
|
try {
|
|
50
78
|
const png = (await within(view.screenshot({ encoding: "buffer" }), 5000, "screenshot")) as Uint8Array;
|
|
51
79
|
const hash = Bun.hash(png).toString(16);
|
|
@@ -55,8 +83,16 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
55
83
|
}
|
|
56
84
|
} catch {
|
|
57
85
|
/* view busy or closed */
|
|
86
|
+
} finally {
|
|
87
|
+
sampling = null;
|
|
58
88
|
}
|
|
59
|
-
|
|
89
|
+
})();
|
|
90
|
+
return sampling;
|
|
91
|
+
};
|
|
92
|
+
const sampler = (async () => {
|
|
93
|
+
while (running) {
|
|
94
|
+
await sampleOnce();
|
|
95
|
+
await Bun.sleep(loaded ? 1000 / bcfg.fps : 50);
|
|
60
96
|
}
|
|
61
97
|
})();
|
|
62
98
|
|
|
@@ -83,17 +119,21 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
83
119
|
const outcome = await Promise.race([navigation, Bun.sleep(750).then(() => "tick" as const)]);
|
|
84
120
|
if (outcome === "ok") {
|
|
85
121
|
currentUrl = url;
|
|
122
|
+
loaded = true;
|
|
86
123
|
return;
|
|
87
124
|
}
|
|
88
125
|
if (outcome === "failed") navigation = null;
|
|
89
126
|
if (!running) return;
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
// No evaluate() here: the view's own loading flag and URL say whether the page arrived (some backends keep
|
|
128
|
+
// the navigate() promise pending long after the page is up, and may leave view.url empty).
|
|
129
|
+
const href = (view.url ?? "").replace(/\/$/, "");
|
|
130
|
+
if (outcome === "tick" && (href.startsWith(target) || (!view.loading && href === ""))) {
|
|
92
131
|
currentUrl = url;
|
|
132
|
+
loaded = true;
|
|
93
133
|
return;
|
|
94
134
|
}
|
|
95
|
-
if (performance.now() > deadline) {
|
|
96
|
-
throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"},
|
|
135
|
+
if (performance.now() > startupDeadline(deadline)) {
|
|
136
|
+
throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, loading: ${view.loading}`);
|
|
97
137
|
}
|
|
98
138
|
await Bun.sleep(250);
|
|
99
139
|
}
|
|
@@ -110,18 +150,35 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
110
150
|
const timeout = toMs(waitOpts.timeout, config.waitTimeout);
|
|
111
151
|
const deadline = performance.now() + timeout;
|
|
112
152
|
for (;;) {
|
|
113
|
-
const text = String((await
|
|
114
|
-
if (regex.test(text))
|
|
115
|
-
|
|
153
|
+
const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
|
|
154
|
+
if (regex.test(text)) {
|
|
155
|
+
loaded = true; // a page that answers is a page worth sampling, even if navigate() has not settled yet
|
|
156
|
+
await sampleOnce(); // the frame the script waited for, captured the moment it appeared
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
|
|
116
160
|
await Bun.sleep(150);
|
|
117
161
|
}
|
|
118
162
|
},
|
|
119
|
-
|
|
163
|
+
/** Real input emulation first; if the browser's actionability checks stall, a DOM click still drives the page. */
|
|
164
|
+
click: async (selector) => {
|
|
165
|
+
loaded = true;
|
|
166
|
+
try {
|
|
167
|
+
await within(view.click(selector), 3000, "click");
|
|
168
|
+
} catch {
|
|
169
|
+
const hit = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`, 5000, "click");
|
|
170
|
+
if (hit !== true) throw new Error(`browser.click: no element matches ${selector}`);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
120
173
|
reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
|
|
121
|
-
evaluate: (js) =>
|
|
174
|
+
evaluate: (js) => evaluate(js, 10000, "evaluate"),
|
|
122
175
|
async stop() {
|
|
176
|
+
// A session shorter than the browser's start-up (cold Chrome on CI) should still show the page once.
|
|
177
|
+
if (frames.length === 0 && initialLoad) await Promise.race([initialLoad, Bun.sleep(Math.max(0, startupDeadline(0) - performance.now()))]);
|
|
178
|
+
if (!loaded && bcfg.url) await evaluate("document.readyState", 2000, "probe").catch(() => undefined); // marks loaded if the page answers
|
|
123
179
|
running = false;
|
|
124
180
|
await sampler;
|
|
181
|
+
await sampleOnce(); // final state of the page
|
|
125
182
|
try {
|
|
126
183
|
view.close();
|
|
127
184
|
} catch {
|
|
@@ -130,6 +187,6 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
|
|
|
130
187
|
},
|
|
131
188
|
};
|
|
132
189
|
|
|
133
|
-
|
|
190
|
+
const initialLoad = bcfg.url ? goto(bcfg.url).catch((err) => log(String(err))) : null;
|
|
134
191
|
return capture;
|
|
135
192
|
}
|
package/src/recorder.ts
CHANGED
|
@@ -78,6 +78,28 @@ export function shellSetup(config: ResolvedConfig): ShellSetup {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
let cachedLang: string | null = null;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A UTF-8 locale the shell can actually switch to. A LANG naming a locale that is not installed (en_US.UTF-8 on a
|
|
85
|
+
* minimal Debian, say) silently drops bash into the C locale, where readline mangles multi-byte input such as emoji.
|
|
86
|
+
*/
|
|
87
|
+
export function defaultLang(): string {
|
|
88
|
+
if (cachedLang !== null) return cachedLang;
|
|
89
|
+
const current = process.env.LANG ?? "";
|
|
90
|
+
const available = new Set<string>();
|
|
91
|
+
try {
|
|
92
|
+
const proc = Bun.spawnSync(["locale", "-a"], { stdout: "pipe", stderr: "ignore" });
|
|
93
|
+
for (const line of proc.stdout.toString().split("\n")) available.add(line.trim().toLowerCase().replace("utf8", "utf-8"));
|
|
94
|
+
} catch {
|
|
95
|
+
/* no `locale` binary: keep the first candidate */
|
|
96
|
+
}
|
|
97
|
+
const has = (name: string) => available.size === 0 || available.has(name.toLowerCase());
|
|
98
|
+
const candidates = [current, "C.UTF-8", "en_US.UTF-8"].filter((c) => /utf-?8/i.test(c));
|
|
99
|
+
cachedLang = candidates.find(has) ?? candidates[candidates.length - 1] ?? "C.UTF-8";
|
|
100
|
+
return cachedLang;
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
/** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
|
|
82
104
|
export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
|
|
83
105
|
const log = opts.log ?? (() => {});
|
|
@@ -111,7 +133,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
111
133
|
...inheritedEnv,
|
|
112
134
|
TERM: "xterm-256color",
|
|
113
135
|
COLORTERM: "truecolor",
|
|
114
|
-
LANG:
|
|
136
|
+
LANG: defaultLang(),
|
|
115
137
|
...setup.env,
|
|
116
138
|
...config.env,
|
|
117
139
|
};
|
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// One place that knows how to open a headless Bun.WebView on every platform.
|
|
2
|
+
// macOS uses the system WebKit; everywhere else Bun drives Chrome/Chromium over the DevTools protocol.
|
|
3
|
+
|
|
4
|
+
export interface ViewSize {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Chrome flags that make headless rendering work in containers and CI without changing what is drawn. */
|
|
10
|
+
export function chromeArgs(): string[] {
|
|
11
|
+
const args = ["--force-device-scale-factor=1", "--hide-scrollbars", "--disable-dev-shm-usage"];
|
|
12
|
+
// Chrome refuses to start its sandbox as root (Docker, most CI runners); unprivileged users keep it.
|
|
13
|
+
if (process.getuid?.() === 0) args.push("--no-sandbox");
|
|
14
|
+
return args;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which backend to use: WebKit on macOS unless TCUT_WEBVIEW=chrome, Chrome elsewhere. */
|
|
18
|
+
export function webViewBackend(): Bun.WebView.Backend {
|
|
19
|
+
const forced = process.env.TCUT_WEBVIEW;
|
|
20
|
+
if (process.platform === "darwin" && forced !== "chrome") return "webkit";
|
|
21
|
+
return {
|
|
22
|
+
type: "chrome",
|
|
23
|
+
url: false, // always spawn our own headless Chrome; never attach to a user's running browser
|
|
24
|
+
argv: chromeArgs(),
|
|
25
|
+
stderr: process.env.TCUT_DEBUG_CHROME ? "inherit" : "ignore",
|
|
26
|
+
...(process.env.BUN_CHROME_PATH && { path: process.env.BUN_CHROME_PATH }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createWebView(size: ViewSize): Bun.WebView {
|
|
31
|
+
if (!Bun.WebView) throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
|
|
32
|
+
try {
|
|
33
|
+
return new Bun.WebView({ ...size, backend: webViewBackend() });
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
const hint =
|
|
36
|
+
process.platform === "darwin"
|
|
37
|
+
? ""
|
|
38
|
+
: " Rendering pixels on Linux/Windows needs Chrome or Chromium on the PATH (or BUN_CHROME_PATH=/path/to/chrome); SVG, HTML and TXT output need no browser.";
|
|
39
|
+
throw new Error(`Could not start the headless browser: ${cause instanceof Error ? cause.message : String(cause)}.${hint}`, { cause });
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/renderer/webview.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createSinks, type Chapter } from "./encoder";
|
|
|
10
10
|
import { chipBuilder } from "../keylabels";
|
|
11
11
|
import { BROWSER_GAP, barHeight, opaqueFill, renderHtml, themeOsc } from "./page";
|
|
12
12
|
import { decodePng, encodePng, luminance, matte, parseHex } from "./png";
|
|
13
|
+
import { createWebView } from "./view";
|
|
13
14
|
|
|
14
15
|
export interface RenderResult {
|
|
15
16
|
outputs: string[];
|
|
@@ -60,10 +61,6 @@ export async function render(
|
|
|
60
61
|
config: ResolvedConfig,
|
|
61
62
|
onProgress?: (p: RenderProgress) => void,
|
|
62
63
|
): Promise<RenderResult> {
|
|
63
|
-
if (!Bun.WebView) {
|
|
64
|
-
throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
64
|
const assets = await pageAssets();
|
|
68
65
|
const html = renderHtml(config);
|
|
69
66
|
const osc = themeOsc(config.theme);
|
|
@@ -121,7 +118,7 @@ export async function render(
|
|
|
121
118
|
const rgbA = parseHex(fillA);
|
|
122
119
|
const rgbB = parseHex(fillB);
|
|
123
120
|
|
|
124
|
-
const view =
|
|
121
|
+
const view = createWebView({ width: 800, height: 600 });
|
|
125
122
|
try {
|
|
126
123
|
await view.navigate(`http://127.0.0.1:${server.port}/`);
|
|
127
124
|
for (let i = 0; i < 100; i++) {
|
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 {
|