@bugabinga/pi-ext-diff-review 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { collectDiff } from "../git.js";
8
+ import { parseArgs } from "../args.js";
9
+ import { startReviewServer, type ReviewServer } from "../server.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+ const EXT_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const CHROME_COMMANDS = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"];
14
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
15
+
16
+ type CdpClient = Awaited<ReturnType<typeof cdp>>;
17
+
18
+ async function main() {
19
+ await assertBundleSplit();
20
+ const chrome = process.env.CHROME ?? await findChrome();
21
+ if (!chrome) {
22
+ console.log("browser-regression: bundle checks ok; no Chrome/Chromium found, skipping CDP tests");
23
+ return;
24
+ }
25
+ await testSearchNavigationAndInputs(chrome);
26
+ console.log("browser-regression: bundle split, search navigation, Escape, notes autogrow, content-visibility ok");
27
+ }
28
+
29
+ async function assertBundleSplit() {
30
+ await execFileAsync("bun", ["run", "build"], { cwd: EXT_DIR, maxBuffer: 20 * 1024 * 1024 });
31
+ const app = await stat(join(EXT_DIR, "static", "app.js"));
32
+ if (app.size > 128 * 1024) throw new Error(`expected split app.js <=128 KiB, got ${app.size} bytes`);
33
+ }
34
+
35
+ async function testSearchNavigationAndInputs(chrome: string) {
36
+ const repo = await mkdtemp(join(tmpdir(), "diff-review-regression-repo-"));
37
+ const profile = await mkdtemp(join(tmpdir(), "diff-review-regression-chrome-"));
38
+ const port = 10_500 + Math.floor(Math.random() * 1000);
39
+ let browser: ChildProcess | undefined;
40
+ let server: ReviewServer | undefined;
41
+ try {
42
+ await prepareMultiHunkRepo(repo);
43
+ server = await startReviewServer({ diff: await collectDiff(repo, parseArgs("")), state: { round: 0, findings: [], rounds: [] }, round: 1, timeoutMs: 45_000 });
44
+ browser = spawn(chrome, ["--headless=new", "--no-sandbox", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "--window-size=1200,800", server.url], { stdio: "ignore" });
45
+ const tabInfo = await tab(port);
46
+ const client = await cdp(tabInfo.webSocketDebuggerUrl);
47
+ try {
48
+ await client.call("Runtime.enable");
49
+ await waitForReady(client);
50
+ await assertContentVisibility(client);
51
+ await assertNotesAutogrow(client);
52
+ await assertSearchLineMappingEscapeAndHorizontalScroll(client);
53
+ } finally {
54
+ client.close();
55
+ }
56
+ } finally {
57
+ browser?.kill();
58
+ server?.close("shutdown");
59
+ await rm(repo, { recursive: true, force: true });
60
+ await rm(profile, { recursive: true, force: true });
61
+ }
62
+ }
63
+
64
+ async function prepareMultiHunkRepo(repo: string) {
65
+ await git(repo, ["init", "-q"]);
66
+ await git(repo, ["config", "user.email", "regression@example.invalid"]);
67
+ await git(repo, ["config", "user.name", "Regression"]);
68
+ const lines = Array.from({ length: 220 }, (_, i) => `line ${i + 1}`);
69
+ lines[8] = `needle at nine ${"n".repeat(260)}`;
70
+ lines[191] = `needle at one ninety two ${"x".repeat(260)}`;
71
+ await writeFile(join(repo, "a.txt"), `${lines.join("\n")}\n`);
72
+ await git(repo, ["add", "a.txt"]);
73
+ await git(repo, ["commit", "-qm", "initial"]);
74
+ lines[8] = `needle at nine changed ${"m".repeat(260)}`;
75
+ lines[191] = `needle at one ninety two changed ${"y".repeat(260)}`;
76
+ await writeFile(join(repo, "a.txt"), `${lines.join("\n")}\n`);
77
+ }
78
+
79
+ async function waitForReady(client: CdpClient) {
80
+ for (let i = 0; i < 120; i++) {
81
+ const ready = await client.eval(`document.querySelector('#status')?.textContent === 'Ready' && !!document.querySelector('diffs-container')`);
82
+ if (ready) return;
83
+ await sleep(250);
84
+ }
85
+ throw new Error("browser did not reach Ready state");
86
+ }
87
+
88
+ async function assertContentVisibility(client: CdpClient) {
89
+ const value = await client.eval(`getComputedStyle(document.querySelector('.file-section')).contentVisibility`);
90
+ if (value !== "auto") throw new Error(`content-visibility not active: ${value}`);
91
+ }
92
+
93
+ async function assertNotesAutogrow(client: CdpClient) {
94
+ const result = await client.eval(`(async () => {
95
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
96
+ const notes = document.querySelector('#notes');
97
+ const before = notes.getBoundingClientRect().height;
98
+ notes.value = Array.from({ length: 22 }, (_, i) => 'note line ' + i).join('\\n');
99
+ notes.dispatchEvent(new Event('input', { bubbles: true }));
100
+ await wait(80);
101
+ const after = notes.getBoundingClientRect().height;
102
+ return { before, after, overflowY: getComputedStyle(notes).overflowY };
103
+ })()`);
104
+ if (!(result.after > result.before)) throw new Error(`notes did not grow: ${JSON.stringify(result)}`);
105
+ if (result.overflowY !== "hidden") throw new Error(`notes should avoid internal scrollbar: ${JSON.stringify(result)}`);
106
+ }
107
+
108
+ async function assertSearchLineMappingEscapeAndHorizontalScroll(client: CdpClient) {
109
+ const result = await client.eval(`(async () => {
110
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
111
+ const input = document.querySelector('#search');
112
+ input.focus();
113
+ input.value = 'needle';
114
+ input.dispatchEvent(new Event('input', { bubbles: true }));
115
+ for (let i = 0; i < 80; i++) {
116
+ await wait(100);
117
+ const results = [...document.querySelectorAll('.search-result')].map((node) => node.textContent || '');
118
+ if (results.some((text) => text.includes(':192 +'))) break;
119
+ }
120
+ const resultTexts = [...document.querySelectorAll('.search-result')].map((node) => node.textContent || '');
121
+ const target = [...document.querySelectorAll('.search-result')].find((node) => (node.textContent || '').includes(':192 +'));
122
+ const root = document.querySelector('[data-file-section="a.txt"] diffs-container').shadowRoot;
123
+ for (const code of root.querySelectorAll('code[data-code]')) code.scrollLeft = 420;
124
+ const beforeScrollLeft = [...root.querySelectorAll('code[data-code]')].map((code) => code.scrollLeft);
125
+ target?.click();
126
+ await wait(700);
127
+ const highlighted = [...root.querySelectorAll('.search-highlight[data-line]')].map((line) => line.getAttribute('data-line'));
128
+ const afterScrollLeft = [...root.querySelectorAll('code[data-code]')].map((code) => code.scrollLeft);
129
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
130
+ await wait(100);
131
+ return {
132
+ resultTexts: resultTexts.slice(0, 8),
133
+ highlighted,
134
+ beforeScrollLeft,
135
+ afterScrollLeft,
136
+ popupHidden: document.querySelector('#search-results').hidden,
137
+ searchValue: input.value,
138
+ };
139
+ })()`);
140
+ if (!result.resultTexts.some((text: string) => text.includes(":9 +")) || !result.resultTexts.some((text: string) => text.includes(":192 +"))) {
141
+ throw new Error(`search results missing real file line numbers: ${JSON.stringify(result.resultTexts)}`);
142
+ }
143
+ if (!result.highlighted.includes("192")) throw new Error(`search did not highlight line 192: ${JSON.stringify(result)}`);
144
+ if (result.beforeScrollLeft.some((value: number, i: number) => result.afterScrollLeft[i] !== value)) {
145
+ throw new Error(`search jump changed horizontal scroll: ${JSON.stringify(result)}`);
146
+ }
147
+ if (!result.popupHidden || result.searchValue !== "needle") throw new Error(`Escape did not close popup and preserve value: ${JSON.stringify(result)}`);
148
+ }
149
+
150
+ async function git(cwd: string, args: string[]) {
151
+ await execFileAsync("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 });
152
+ }
153
+
154
+ async function findChrome() {
155
+ for (const command of CHROME_COMMANDS) {
156
+ try {
157
+ await execFileAsync(command, ["--version"], { maxBuffer: 1024 * 1024 });
158
+ return command;
159
+ } catch {}
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ async function tab(port: number): Promise<{ webSocketDebuggerUrl: string }> {
165
+ for (let i = 0; i < 80; i++) {
166
+ try {
167
+ const tabs = await fetch(`http://127.0.0.1:${port}/json/list`).then((res) => res.json()) as Array<{ type: string; webSocketDebuggerUrl: string }>;
168
+ const page = tabs.find((entry) => entry.type === "page");
169
+ if (page) return page;
170
+ } catch {}
171
+ await sleep(250);
172
+ }
173
+ throw new Error("Chrome CDP tab not available");
174
+ }
175
+
176
+ async function cdp(url: string) {
177
+ let id = 0;
178
+ const ws = new WebSocket(url);
179
+ const pending = new Map<number, (message: any) => void>();
180
+ ws.onmessage = (event) => {
181
+ const message = JSON.parse(String(event.data));
182
+ pending.get(message.id)?.(message);
183
+ pending.delete(message.id);
184
+ };
185
+ await new Promise<void>((resolve) => { ws.onopen = () => resolve(); });
186
+ return {
187
+ call(method: string, params: Record<string, unknown> = {}) {
188
+ return new Promise<any>((resolve) => {
189
+ const callId = ++id;
190
+ pending.set(callId, resolve);
191
+ ws.send(JSON.stringify({ id: callId, method, params }));
192
+ });
193
+ },
194
+ async eval(expression: string) {
195
+ const result = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
196
+ if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails));
197
+ return result.result?.result?.value;
198
+ },
199
+ close() { ws.close(); },
200
+ };
201
+ }
202
+
203
+ main().catch((err) => {
204
+ console.error(err);
205
+ process.exit(1);
206
+ });
@@ -0,0 +1,56 @@
1
+ declare const Bun: {
2
+ version: string;
3
+ build(options: {
4
+ entrypoints: string[];
5
+ outdir: string;
6
+ target: "browser";
7
+ minify: boolean;
8
+ sourcemap: "none";
9
+ splitting?: boolean;
10
+ naming: { entry: string; chunk: string; asset: string };
11
+ }): Promise<{ success: boolean; logs: unknown[] }>;
12
+ };
13
+
14
+ import { mkdir, rm, copyFile, writeFile } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { sourceManifest } from "../bundle.js";
18
+ import pkg from "../package.json" assert { type: "json" };
19
+
20
+ const EXT_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
21
+ const STATIC_DIR = join(EXT_DIR, "static");
22
+ const BROWSER_DIR = join(EXT_DIR, "browser");
23
+
24
+ await rm(STATIC_DIR, { recursive: true, force: true });
25
+ await mkdir(STATIC_DIR, { recursive: true });
26
+ await copyFile(join(BROWSER_DIR, "index.html"), join(STATIC_DIR, "index.html"));
27
+
28
+ const result = await Bun.build({
29
+ entrypoints: [join(BROWSER_DIR, "index.ts")],
30
+ outdir: STATIC_DIR,
31
+ target: "browser",
32
+ minify: true,
33
+ sourcemap: "none",
34
+ splitting: true,
35
+ naming: {
36
+ entry: "app.[ext]",
37
+ chunk: "assets/[name]-[hash].[ext]",
38
+ asset: "assets/[name]-[hash].[ext]",
39
+ },
40
+ });
41
+
42
+ if (!result.success) {
43
+ for (const log of result.logs) console.error(log);
44
+ process.exit(1);
45
+ }
46
+
47
+ await writeFile(join(STATIC_DIR, "manifest.json"), JSON.stringify({
48
+ bunVersion: Bun.version,
49
+ packageVersions: {
50
+ "@pierre/diffs": pkg.dependencies["@pierre/diffs"],
51
+ "@pierre/trees": pkg.dependencies["@pierre/trees"],
52
+ },
53
+ sources: await sourceManifest(),
54
+ }, null, 2));
55
+
56
+ console.log(`built diff-review browser bundle in ${STATIC_DIR}`);
@@ -0,0 +1,268 @@
1
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+ import { collectDiff } from "../git.js";
8
+ import { parseArgs } from "../args.js";
9
+ import { startReviewServer } from "../server.js";
10
+ import type { BrowserResult } from "../types.js";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const EXT_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
14
+ const BUILD_BUFFER_BYTES = 10 * 1024 * 1024;
15
+ const REVIEW_TIMEOUT_MS = 30_000;
16
+ const DEBUG_PORT_BASE = 9400;
17
+ const DEBUG_PORT_SPAN = 1000;
18
+ const TAB_RETRIES = 80;
19
+ const TAB_POLL_MS = 250;
20
+ const BROWSER_BOOT_MS = 5_000;
21
+ const CHROME_COMMANDS = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"];
22
+
23
+ async function main() {
24
+ await execFileAsync("bun", ["run", "build"], { cwd: EXT_DIR, maxBuffer: BUILD_BUFFER_BYTES });
25
+ await assertStaticBundle();
26
+ await smokeRealBrowser();
27
+ }
28
+
29
+ async function assertStaticBundle() {
30
+ const [index, app, manifest] = await Promise.all([
31
+ readText(join(EXT_DIR, "static", "index.html")),
32
+ readText(join(EXT_DIR, "static", "app.js")),
33
+ readText(join(EXT_DIR, "static", "manifest.json")),
34
+ ]);
35
+ if (!index || !app || !manifest) throw new Error("static browser bundle missing; run bun run build");
36
+ if (!index.includes("/static/app.js")) throw new Error("static/index.html does not load /static/app.js");
37
+ JSON.parse(manifest);
38
+ }
39
+
40
+ async function smokeRealBrowser() {
41
+ const chrome = process.env.CHROME ?? await findChrome();
42
+ if (!chrome) return console.log("smoke: static bundle ok; no Chrome/Chromium found, skipping real browser smoke");
43
+
44
+ const ctx = await createSmokeContext(chrome);
45
+ try {
46
+ await prepareRepo(ctx.repo);
47
+ const running = await startSmokeServer(ctx.repo);
48
+ ctx.server = running.server;
49
+ ctx.submitted = running.submitted;
50
+ ctx.chrome = launchChrome(chrome, ctx.profile, ctx.debugPort, ctx.server.url);
51
+ await exerciseBrowser(ctx.debugPort, ctx.server, ctx.submitted);
52
+ console.log("smoke: real browser loaded UI, switched theme, loaded font, added/removed finding, submitted review");
53
+ } finally {
54
+ await cleanup(ctx);
55
+ }
56
+ }
57
+
58
+ type SmokeContext = {
59
+ repo: string;
60
+ profile: string;
61
+ debugPort: number;
62
+ server?: Awaited<ReturnType<typeof startReviewServer>>;
63
+ submitted?: Promise<Extract<BrowserResult, { type: "submit" }>>;
64
+ chrome?: ChildProcess;
65
+ };
66
+
67
+ async function createSmokeContext(_chrome: string): Promise<SmokeContext> {
68
+ return {
69
+ repo: await mkdtemp(join(tmpdir(), "diff-review-smoke-repo-")),
70
+ profile: await mkdtemp(join(tmpdir(), "diff-review-smoke-chrome-")),
71
+ debugPort: DEBUG_PORT_BASE + Math.floor(Math.random() * DEBUG_PORT_SPAN),
72
+ };
73
+ }
74
+
75
+ async function prepareRepo(repo: string) {
76
+ await git(repo, ["init", "-q"]);
77
+ await git(repo, ["config", "user.email", "smoke@example.invalid"]);
78
+ await git(repo, ["config", "user.name", "smoke"]);
79
+ await writeFile(join(repo, "a.ts"), "const a = 1;\n");
80
+ await git(repo, ["add", "a.ts"]);
81
+ await git(repo, ["commit", "-qm", "init"]);
82
+ await writeFile(join(repo, "a.ts"), "const a = 2;\nconst b = 3;\n");
83
+ }
84
+
85
+ async function startSmokeServer(repo: string) {
86
+ const diff = await collectDiff(repo, parseArgs(""));
87
+ let resolveSubmit!: (result: Extract<BrowserResult, { type: "submit" }>) => void;
88
+ const submitted = new Promise<Extract<BrowserResult, { type: "submit" }>>((resolve) => { resolveSubmit = resolve; });
89
+ const server = await startReviewServer({ diff, state: { round: 0, findings: [], rounds: [] }, round: 1, timeoutMs: REVIEW_TIMEOUT_MS, onSubmit: resolveSubmit });
90
+ return { server, submitted };
91
+ }
92
+
93
+ function launchChrome(chrome: string, profile: string, debugPort: number, url: string) {
94
+ return spawn(chrome, [
95
+ "--headless=new",
96
+ "--disable-gpu",
97
+ "--no-sandbox",
98
+ "--disable-dev-shm-usage",
99
+ `--remote-debugging-port=${debugPort}`,
100
+ `--user-data-dir=${profile}`,
101
+ "--window-size=1400,900",
102
+ url,
103
+ ], { stdio: "ignore" });
104
+ }
105
+
106
+ async function exerciseBrowser(debugPort: number, server: Awaited<ReturnType<typeof startReviewServer>>, submitted: Promise<Extract<BrowserResult, { type: "submit" }>>) {
107
+ const tab = await waitForTab(debugPort);
108
+ const client = await connectCdp(tab.webSocketDebuggerUrl);
109
+ try {
110
+ await client.call("Runtime.enable");
111
+ await sleep(BROWSER_BOOT_MS);
112
+ assertBrowserSmokeResult(await client.eval(BROWSER_SCRIPT));
113
+ const result = await submitted;
114
+ if (result.findings.length !== 1 || result.findings[0]?.comment !== "smoke finding") throw new Error("server did not receive expected submitted finding");
115
+ server.close("browser");
116
+ } finally {
117
+ client.close();
118
+ }
119
+ }
120
+
121
+ const BROWSER_SCRIPT = String.raw`(async () => {
122
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
123
+ const must = (selector, root = document) => {
124
+ const el = root.querySelector(selector);
125
+ if (!el) throw new Error('missing ' + selector);
126
+ return el;
127
+ };
128
+ const clickLine = async (line, y) => {
129
+ line.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 900, clientY: y }));
130
+ await wait(200);
131
+ };
132
+ const addFinding = async (selector, commentText, y) => {
133
+ const shadowRoot = must('diffs-container').shadowRoot;
134
+ await clickLine(must(selector, shadowRoot), y);
135
+ const comment = must('#comment');
136
+ comment.value = commentText;
137
+ comment.dispatchEvent(new Event('input', { bubbles: true }));
138
+ must('#add').click();
139
+ await wait(400);
140
+ };
141
+
142
+ await document.fonts.ready;
143
+ const themeMode = must('#theme-mode');
144
+ themeMode.value = 'light';
145
+ themeMode.dispatchEvent(new Event('change', { bubbles: true }));
146
+
147
+ await addFinding('[data-additions] [data-line="1"]', 'remove me', 350);
148
+ await addFinding('[data-additions] [data-line="2"]', 'smoke finding', 370);
149
+ must('.remove-finding').click();
150
+ await wait(200);
151
+
152
+ const failedResources = performance.getEntriesByType('resource')
153
+ .filter((entry) => 'responseStatus' in entry && entry.responseStatus >= 400)
154
+ .map((entry) => [entry.name, entry.responseStatus]);
155
+ const result = {
156
+ findingsText: must('#findings').innerText,
157
+ failedResources,
158
+ fontLoaded: document.fonts.check('12px "JetBrains Mono Nerd"'),
159
+ theme: document.documentElement.dataset.theme,
160
+ };
161
+ must('#submit').click();
162
+ return result;
163
+ })()`;
164
+
165
+ type BrowserSmokeResult = {
166
+ findingsText: string;
167
+ failedResources: Array<[string, number]>;
168
+ fontLoaded: boolean;
169
+ theme: string;
170
+ };
171
+
172
+ function assertBrowserSmokeResult(value: unknown): asserts value is BrowserSmokeResult {
173
+ if (!isRecord(value)) throw new Error("browser smoke returned invalid result");
174
+ if (typeof value.findingsText !== "string") throw new Error("browser smoke missing findings text");
175
+ if (!value.findingsText.includes("smoke finding") || value.findingsText.includes("remove me")) throw new Error("finding add/remove flow failed in browser UI");
176
+ if (value.fontLoaded !== true) throw new Error("JetBrains Mono Nerd font did not load");
177
+ if (value.theme !== "light") throw new Error("theme selector did not apply light mode");
178
+ if (Array.isArray(value.failedResources) && value.failedResources.length > 0) throw new Error(`resource failures: ${JSON.stringify(value.failedResources)}`);
179
+ }
180
+
181
+ async function cleanup(ctx: SmokeContext) {
182
+ ctx.chrome?.kill();
183
+ ctx.server?.close("timeout");
184
+ await Promise.all([
185
+ rm(ctx.repo, { recursive: true, force: true }),
186
+ rm(ctx.profile, { recursive: true, force: true }),
187
+ ]);
188
+ }
189
+
190
+ async function waitForTab(port: number): Promise<{ webSocketDebuggerUrl: string }> {
191
+ for (let i = 0; i < TAB_RETRIES; i++) {
192
+ const tab = await localChromeTabs(port).catch(() => undefined);
193
+ if (tab) return tab;
194
+ await sleep(TAB_POLL_MS);
195
+ }
196
+ throw new Error("Chrome CDP tab not found");
197
+ }
198
+
199
+ async function localChromeTabs(port: number) {
200
+ const tabs = await fetch(`http://127.0.0.1:${port}/json/list`).then((res) => res.json()) as Array<{ type: string; url: string; webSocketDebuggerUrl: string }>;
201
+ return tabs.find((candidate) => candidate.type === "page" && candidate.url.startsWith("http://127.0.0.1"));
202
+ }
203
+
204
+ type CdpClient = {
205
+ call(method: string, params?: Record<string, unknown>): Promise<CdpResponse>;
206
+ eval(expression: string): Promise<unknown>;
207
+ close(): void;
208
+ };
209
+ type CdpResponse = { id?: number; result?: { result?: { value?: unknown }; exceptionDetails?: { text?: string } }; error?: { message?: string } };
210
+
211
+ async function connectCdp(url: string): Promise<CdpClient> {
212
+ let id = 0;
213
+ const ws = new WebSocket(url);
214
+ const pending = new Map<number, (value: CdpResponse) => void>();
215
+ ws.onmessage = (event) => receiveCdpMessage(pending, event.data);
216
+ await new Promise<void>((resolve, reject) => { ws.onopen = () => resolve(); ws.onerror = reject; });
217
+ return {
218
+ call(method: string, params: Record<string, unknown> = {}) {
219
+ return new Promise<CdpResponse>((resolve) => {
220
+ const callId = ++id;
221
+ pending.set(callId, resolve);
222
+ ws.send(JSON.stringify({ id: callId, method, params }));
223
+ });
224
+ },
225
+ async eval(expression: string) {
226
+ const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
227
+ if (response.error) throw new Error(response.error.message ?? "browser eval protocol error");
228
+ if (response.result?.exceptionDetails) throw new Error(response.result.exceptionDetails.text ?? "browser eval failed");
229
+ return response.result?.result?.value;
230
+ },
231
+ close() { ws.close(); },
232
+ };
233
+ }
234
+
235
+ function receiveCdpMessage(pending: Map<number, (value: CdpResponse) => void>, data: unknown) {
236
+ const message = JSON.parse(String(data)) as CdpResponse;
237
+ if (!message.id) return;
238
+ pending.get(message.id)?.(message);
239
+ pending.delete(message.id);
240
+ }
241
+
242
+ async function findChrome() {
243
+ for (const command of CHROME_COMMANDS) {
244
+ try {
245
+ await execFileAsync(command, ["--version"]);
246
+ return command;
247
+ } catch {}
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ async function git(cwd: string, args: string[]) {
253
+ await execFileAsync("git", args, { cwd });
254
+ }
255
+
256
+ async function readText(path: string) {
257
+ return readFile(path, "utf8").catch(() => "");
258
+ }
259
+
260
+ function sleep(ms: number) {
261
+ return new Promise((resolve) => setTimeout(resolve, ms));
262
+ }
263
+
264
+ function isRecord(value: unknown): value is Record<string, unknown> {
265
+ return !!value && typeof value === "object" && !Array.isArray(value);
266
+ }
267
+
268
+ await main();