termcut 0.7.1 → 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 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 renders pixels through Chrome/Chromium on the PATH.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
@@ -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,10 @@ 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 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));
51
55
  // A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
52
56
  let chain: Promise<unknown> = Promise.resolve();
53
57
  const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
@@ -58,7 +62,13 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
58
62
  );
59
63
  return next;
60
64
  };
61
- const evaluate = (js: string, ms: number, label: string): Promise<unknown> => serial(() => within(view.evaluate(js), ms, label));
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
+ });
62
72
  // One screenshot at a time: the periodic sampler and the event-driven samples (after waitFor, at stop) share it.
63
73
  let sampling: Promise<void> | null = null;
64
74
  const sampleOnce = (): Promise<void> => {
@@ -114,16 +124,16 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
114
124
  }
115
125
  if (outcome === "failed") navigation = null;
116
126
  if (!running) return;
117
- const state = await evaluate("document.readyState", 3000, "goto").catch(() => "");
118
- // Some backends leave view.url empty; then a complete document is the best signal we have.
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).
119
129
  const href = (view.url ?? "").replace(/\/$/, "");
120
- if (state === "complete" && (href === "" || href.startsWith(target))) {
130
+ if (outcome === "tick" && (href.startsWith(target) || (!view.loading && href === ""))) {
121
131
  currentUrl = url;
122
132
  loaded = true;
123
133
  return;
124
134
  }
125
- if (performance.now() > deadline) {
126
- throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, readyState: ${state || "unknown"}`);
135
+ if (performance.now() > startupDeadline(deadline)) {
136
+ throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, loading: ${view.loading}`);
127
137
  }
128
138
  await Bun.sleep(250);
129
139
  }
@@ -146,7 +156,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
146
156
  await sampleOnce(); // the frame the script waited for, captured the moment it appeared
147
157
  return;
148
158
  }
149
- if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
159
+ if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
150
160
  await Bun.sleep(150);
151
161
  }
152
162
  },
@@ -163,6 +173,9 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
163
173
  reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
164
174
  evaluate: (js) => evaluate(js, 10000, "evaluate"),
165
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
166
179
  running = false;
167
180
  await sampler;
168
181
  await sampleOnce(); // final state of the page
@@ -174,6 +187,6 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
174
187
  },
175
188
  };
176
189
 
177
- if (bcfg.url) void goto(bcfg.url).catch((err) => log(String(err)));
190
+ const initialLoad = bcfg.url ? goto(bcfg.url).catch((err) => log(String(err))) : null;
178
191
  return capture;
179
192
  }
@@ -43,8 +43,8 @@ async function embeddedAssets(): Promise<PageAssets | null> {
43
43
  }
44
44
  }
45
45
 
46
- /** True inside a `bun build --compile` binary, where sources live on the virtual /$bunfs filesystem. */
47
- const isCompiled = import.meta.dir.startsWith("/$bunfs");
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/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: "raw"; data: string }
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
- ops.push({ kind: "raw", data: token });
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 {