@crewhaus/computer-use-driver 0.1.4 → 0.1.6

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.
@@ -1,376 +0,0 @@
1
- /**
2
- * Catalog R18 — chromium (Playwright-backed) driver contract tests.
3
- *
4
- * Playwright is an optional peer dep that may be present in the tree. To stay
5
- * deterministic (no real browser launch, no real clock/network), we intercept
6
- * the lazy `import("playwright")` with `mock.module` and drive a fully stubbed
7
- * browser → context → page chain. Each test (re)establishes the mock before
8
- * use because `mock.module` is process-global and one test deliberately makes
9
- * the import fail.
10
- */
11
- import { afterEach, describe, expect, mock, test } from "bun:test";
12
- import * as net from "node:net";
13
- import { createDriver } from "./index.js";
14
-
15
- /** Parse the JSON recorded by the fake's `launch …` entry. */
16
- function parseLaunch(entry: string | undefined): Record<string, unknown> {
17
- if (entry === undefined || !entry.startsWith("launch ")) {
18
- throw new Error(`expected a launch record, got: ${entry}`);
19
- }
20
- return JSON.parse(entry.slice("launch ".length)) as Record<string, unknown>;
21
- }
22
-
23
- /** True once nothing is listening on `port` (loopback). */
24
- function portRefuses(port: number): Promise<boolean> {
25
- return new Promise((resolve) => {
26
- const sock = net.connect({ host: "127.0.0.1", port }, () => {
27
- sock.destroy();
28
- resolve(false);
29
- });
30
- sock.on("error", () => resolve(true));
31
- });
32
- }
33
-
34
- /** Records every call routed through the fake Playwright surface. */
35
- type Rec = string[];
36
-
37
- /** Build a fake `playwright` module whose page records into `rec`. */
38
- function fakePlaywright(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
39
- const page = {
40
- async goto(url: string, optsArg?: unknown) {
41
- rec.push(`goto ${url} ${JSON.stringify(optsArg)}`);
42
- },
43
- async screenshot(optsArg?: { type?: string }) {
44
- rec.push(`screenshot ${optsArg?.type}`);
45
- // PNG magic header so the returned Uint8Array is recognizable.
46
- return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
47
- },
48
- mouse: {
49
- async click(x: number, y: number, optsArg?: { button?: string }) {
50
- rec.push(`click ${x},${y} ${optsArg?.button}`);
51
- },
52
- async wheel(dx: number, dy: number) {
53
- rec.push(`wheel ${dx},${dy}`);
54
- },
55
- },
56
- keyboard: {
57
- async type(t: string) {
58
- rec.push(`type ${t}`);
59
- },
60
- async press(k: string) {
61
- rec.push(`press ${k}`);
62
- },
63
- },
64
- viewportSize: overrides?.viewportSize ?? (() => ({ width: 1280, height: 720 })),
65
- async textContent(sel: string) {
66
- rec.push(`textContent ${sel}`);
67
- return "hello body";
68
- },
69
- };
70
- const context = {
71
- async newPage() {
72
- rec.push("newPage");
73
- return page;
74
- },
75
- };
76
- const browser = {
77
- async newContext(ctxOpts: unknown) {
78
- rec.push(`newContext ${JSON.stringify(ctxOpts)}`);
79
- return context;
80
- },
81
- async close() {
82
- rec.push("close");
83
- },
84
- };
85
- return {
86
- chromium: {
87
- async launch(launchOpts: unknown) {
88
- rec.push(`launch ${JSON.stringify(launchOpts)}`);
89
- return browser;
90
- },
91
- },
92
- __page: page,
93
- __browser: browser,
94
- };
95
- }
96
-
97
- function installPlaywright(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
98
- const fake = fakePlaywright(rec, overrides);
99
- mock.module("playwright", () => fake);
100
- return fake;
101
- }
102
-
103
- afterEach(() => {
104
- // Leave a benign working module registered so per-test fakes cannot bleed
105
- // into unrelated tests / files run later in the process. We deliberately do
106
- // NOT restore the real playwright: it is an optional peer dep that may be
107
- // absent, nothing else in this package's test process imports it, and a
108
- // working fake guarantees no test can ever launch a real browser.
109
- mock.module("playwright", () => fakePlaywright([]));
110
- });
111
-
112
- describe("chromium driver — connect / lifecycle", () => {
113
- test("connect launches with defaults, creates context + page", async () => {
114
- const rec: Rec = [];
115
- installPlaywright(rec);
116
- const d = createDriver({ backend: "chromium" });
117
- await d.connect();
118
- const launch = parseLaunch(rec[0]);
119
- expect(launch["headless"]).toBe(true);
120
- // SECURITY default — the DNS-pinning proxy is on, with the implicit
121
- // localhost proxy-bypass removed (see ssrf-proxy.ts).
122
- expect(launch["proxy"]).toMatchObject({ bypass: "<-loopback>" });
123
- expect((launch["proxy"] as { server: string }).server).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
124
- expect(rec.slice(1)).toEqual([
125
- `newContext ${JSON.stringify({ viewport: { width: 1280, height: 720 } })}`,
126
- "newPage",
127
- ]);
128
- await d.disconnect();
129
- });
130
-
131
- test("connect honours playwrightOptions + viewport overrides", async () => {
132
- const rec: Rec = [];
133
- installPlaywright(rec);
134
- const d = createDriver({
135
- backend: "chromium",
136
- playwrightOptions: { headless: false, slowMo: 5 },
137
- viewport: { width: 800, height: 600 },
138
- });
139
- await d.connect();
140
- const launch = parseLaunch(rec[0]);
141
- expect(launch["headless"]).toBe(false);
142
- expect(launch["slowMo"]).toBe(5);
143
- expect(rec[1]).toBe(`newContext ${JSON.stringify({ viewport: { width: 800, height: 600 } })}`);
144
- await d.disconnect();
145
- });
146
-
147
- test("connect is idempotent — second call is a no-op", async () => {
148
- const rec: Rec = [];
149
- installPlaywright(rec);
150
- const d = createDriver({ backend: "chromium" });
151
- await d.connect();
152
- const afterFirst = rec.length;
153
- await d.connect();
154
- expect(rec.length).toBe(afterFirst);
155
- });
156
-
157
- // The playwright-import-failure path (loadPlaywright catch) is covered in
158
- // chromium-import-fail.test.ts via the `_importPlaywright` injection seam.
159
- // It cannot be tested with mock.module here: bun evaluates a throwing
160
- // factory eagerly once the module has been imported (even as a fake, as
161
- // these tests do), so registration itself would blow up.
162
- });
163
-
164
- describe("chromium driver — operations after connect", () => {
165
- async function connected(rec: Rec, overrides?: { viewportSize?: () => unknown }) {
166
- installPlaywright(rec, overrides);
167
- const d = createDriver({ backend: "chromium" });
168
- await d.connect();
169
- rec.length = 0; // drop connect noise; focus on the operation under test
170
- return d;
171
- }
172
-
173
- test("goto navigates with domcontentloaded wait", async () => {
174
- const rec: Rec = [];
175
- const d = await connected(rec);
176
- await d.goto("https://example.com");
177
- expect(rec).toEqual([
178
- `goto https://example.com ${JSON.stringify({ waitUntil: "domcontentloaded" })}`,
179
- ]);
180
- });
181
-
182
- test("screenshot returns the page PNG bytes", async () => {
183
- const rec: Rec = [];
184
- const d = await connected(rec);
185
- const png = await d.screenshot();
186
- expect(png).toBeInstanceOf(Uint8Array);
187
- expect(Array.from(png)).toEqual([0x89, 0x50, 0x4e, 0x47]);
188
- expect(rec).toEqual(["screenshot png"]);
189
- });
190
-
191
- test("click defaults to the left button", async () => {
192
- const rec: Rec = [];
193
- const d = await connected(rec);
194
- await d.click(10, 20);
195
- expect(rec).toEqual(["click 10,20 left"]);
196
- });
197
-
198
- test("click forwards an explicit button", async () => {
199
- const rec: Rec = [];
200
- const d = await connected(rec);
201
- await d.click(30, 40, "right");
202
- expect(rec).toEqual(["click 30,40 right"]);
203
- });
204
-
205
- test("type forwards to keyboard.type", async () => {
206
- const rec: Rec = [];
207
- const d = await connected(rec);
208
- await d.type("hello world");
209
- expect(rec).toEqual(["type hello world"]);
210
- });
211
-
212
- test("key forwards to keyboard.press", async () => {
213
- const rec: Rec = [];
214
- const d = await connected(rec);
215
- await d.key("Control+a");
216
- expect(rec).toEqual(["press Control+a"]);
217
- });
218
-
219
- test("scroll forwards to mouse.wheel", async () => {
220
- const rec: Rec = [];
221
- const d = await connected(rec);
222
- await d.scroll(0, 240);
223
- expect(rec).toEqual(["wheel 0,240"]);
224
- });
225
-
226
- test("getViewport uses the live page viewportSize when available", async () => {
227
- const rec: Rec = [];
228
- const d = await connected(rec);
229
- const vp = await d.getViewport();
230
- expect(vp).toEqual({ width: 1280, height: 720, devicePixelRatio: 1 });
231
- });
232
-
233
- test("getViewport falls back to opts.viewport when page reports null", async () => {
234
- const rec: Rec = [];
235
- installPlaywright(rec, { viewportSize: () => null });
236
- const d = createDriver({ backend: "chromium", viewport: { width: 640, height: 480 } });
237
- await d.connect();
238
- const vp = await d.getViewport();
239
- expect(vp).toEqual({ width: 640, height: 480, devicePixelRatio: 1 });
240
- });
241
-
242
- test("getViewport falls back to 1280x720 when page is null and no opts viewport", async () => {
243
- const rec: Rec = [];
244
- installPlaywright(rec, { viewportSize: () => null });
245
- const d = createDriver({ backend: "chromium" });
246
- await d.connect();
247
- const vp = await d.getViewport();
248
- expect(vp).toEqual({ width: 1280, height: 720, devicePixelRatio: 1 });
249
- });
250
-
251
- test("domText returns the body text content", async () => {
252
- const rec: Rec = [];
253
- const d = await connected(rec);
254
- const text = await d.domText?.();
255
- expect(text).toBe("hello body");
256
- expect(rec).toEqual(["textContent body"]);
257
- });
258
-
259
- test("domText coalesces null body text to empty string", async () => {
260
- const rec: Rec = [];
261
- const fake = installPlaywright(rec);
262
- // Page reports no body text content → driver must coalesce null → "".
263
- (fake.__page as { textContent: (s: string) => Promise<string | null> }).textContent =
264
- async () => null;
265
- const d = createDriver({ backend: "chromium" });
266
- await d.connect();
267
- const text = await d.domText?.();
268
- expect(text).toBe("");
269
- });
270
- });
271
-
272
- describe("chromium driver — pre-connect guards", () => {
273
- test("every operation throws 'not connected' before connect()", async () => {
274
- const d = createDriver({ backend: "chromium" });
275
- await expect(d.goto("https://x.com")).rejects.toThrow(/not connected/);
276
- await expect(d.screenshot()).rejects.toThrow(/not connected/);
277
- await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
278
- await expect(d.type("x")).rejects.toThrow(/not connected/);
279
- await expect(d.key("Enter")).rejects.toThrow(/not connected/);
280
- await expect(d.scroll(0, 0)).rejects.toThrow(/not connected/);
281
- await expect(d.getViewport()).rejects.toThrow(/not connected/);
282
- await expect(d.domText?.()).rejects.toThrow(/not connected/);
283
- });
284
- });
285
-
286
- describe("chromium driver — SSRF pinning proxy wiring (audit follow-up R1)", () => {
287
- test("the proxy in launchOpts is a LIVE local server, closed by disconnect", async () => {
288
- const rec: Rec = [];
289
- installPlaywright(rec);
290
- const d = createDriver({ backend: "chromium" });
291
- await d.connect();
292
- const launch = parseLaunch(rec[0]);
293
- const url = new URL((launch["proxy"] as { server: string }).server);
294
- const port = Number.parseInt(url.port, 10);
295
- expect(await portRefuses(port)).toBe(false); // listening while connected
296
- await d.disconnect();
297
- expect(await portRefuses(port)).toBe(true); // closed after disconnect
298
- });
299
-
300
- test("ssrfProxy: false disables the proxy (documented escape hatch)", async () => {
301
- const rec: Rec = [];
302
- installPlaywright(rec);
303
- const d = createDriver({ backend: "chromium", ssrfProxy: false });
304
- await d.connect();
305
- const launch = parseLaunch(rec[0]);
306
- expect(launch["proxy"]).toBeUndefined();
307
- await d.disconnect();
308
- });
309
-
310
- test("an operator-supplied playwrightOptions.proxy wins (ours is skipped)", async () => {
311
- const rec: Rec = [];
312
- installPlaywright(rec);
313
- const d = createDriver({
314
- backend: "chromium",
315
- playwrightOptions: { proxy: { server: "http://corp-egress:3128" } },
316
- });
317
- await d.connect();
318
- const launch = parseLaunch(rec[0]);
319
- expect(launch["proxy"]).toEqual({ server: "http://corp-egress:3128" });
320
- await d.disconnect();
321
- });
322
-
323
- test("launch failure closes the proxy instead of leaking it", async () => {
324
- const rec: Rec = [];
325
- const fake = installPlaywright(rec);
326
- (fake.chromium as { launch: (o: unknown) => Promise<unknown> }).launch = async (
327
- launchOpts: unknown,
328
- ) => {
329
- rec.push(`launch ${JSON.stringify(launchOpts)}`);
330
- throw new Error("chromium exploded");
331
- };
332
- const d = createDriver({ backend: "chromium" });
333
- await expect(d.connect()).rejects.toThrow("chromium exploded");
334
- const launch = parseLaunch(rec[0]);
335
- const url = new URL((launch["proxy"] as { server: string }).server);
336
- expect(await portRefuses(Number.parseInt(url.port, 10))).toBe(true);
337
- });
338
- });
339
-
340
- describe("chromium driver — disconnect", () => {
341
- test("disconnect closes the browser and resets state", async () => {
342
- const rec: Rec = [];
343
- installPlaywright(rec);
344
- const d = createDriver({ backend: "chromium" });
345
- await d.connect();
346
- rec.length = 0;
347
- await d.disconnect();
348
- expect(rec).toEqual(["close"]);
349
- // After disconnect, operations once again report "not connected".
350
- await expect(d.screenshot()).rejects.toThrow(/not connected/);
351
- });
352
-
353
- test("disconnect before connect is a no-op", async () => {
354
- const rec: Rec = [];
355
- installPlaywright(rec);
356
- const d = createDriver({ backend: "chromium" });
357
- await d.disconnect();
358
- expect(rec).toEqual([]);
359
- });
360
-
361
- test("disconnect swallows browser.close() failures (best-effort)", async () => {
362
- const rec: Rec = [];
363
- const fake = installPlaywright(rec);
364
- (fake.__browser as { close: () => Promise<void> }).close = async () => {
365
- rec.push("close-throw");
366
- throw new Error("close failed");
367
- };
368
- const d = createDriver({ backend: "chromium" });
369
- await d.connect();
370
- rec.length = 0;
371
- await d.disconnect(); // must not reject
372
- expect(rec).toEqual(["close-throw"]);
373
- // State is still reset despite the close failure.
374
- await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
375
- });
376
- });
package/src/errors.ts DELETED
@@ -1,8 +0,0 @@
1
- import { CrewhausError } from "@crewhaus/errors";
2
-
3
- export class ComputerUseDriverError extends CrewhausError {
4
- override readonly name = "ComputerUseDriverError";
5
- constructor(message: string, cause?: unknown) {
6
- super("tool", message, cause);
7
- }
8
- }
package/src/index.test.ts DELETED
@@ -1,141 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { ComputerUseDriverError, type Driver, createDriver } from "./index.js";
3
-
4
- describe("createDriver", () => {
5
- test("dispatches on backend tag", () => {
6
- const chromium = createDriver({ backend: "chromium" });
7
- expect(chromium.backend).toBe("chromium");
8
- const host = createDriver({ backend: "host" });
9
- expect(host.backend).toBe("host");
10
- const remote = createDriver({ backend: "remote" });
11
- expect(remote.backend).toBe("remote");
12
- });
13
-
14
- test("unknown backend throws", () => {
15
- expect(() => createDriver({ backend: "bogus" as unknown as "host" })).toThrow(
16
- /unknown backend/,
17
- );
18
- });
19
-
20
- test("host backend rejects connect (T8 — kickoff explicitly forbids)", async () => {
21
- const d = createDriver({ backend: "host" });
22
- await expect(d.connect()).rejects.toThrow(ComputerUseDriverError);
23
- });
24
-
25
- test("remote backend rejects connect (v0 stub)", async () => {
26
- const d = createDriver({ backend: "remote" });
27
- await expect(d.connect()).rejects.toThrow(/not implemented in v0/);
28
- });
29
-
30
- test("_injected returns the injected driver verbatim (test seam)", async () => {
31
- const recorded: string[] = [];
32
- const stub: Driver = {
33
- backend: "chromium",
34
- async connect() {
35
- recorded.push("connect");
36
- },
37
- async goto() {
38
- recorded.push("goto");
39
- },
40
- async screenshot() {
41
- recorded.push("screenshot");
42
- return new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG header
43
- },
44
- async click() {
45
- recorded.push("click");
46
- },
47
- async type() {
48
- recorded.push("type");
49
- },
50
- async key() {
51
- recorded.push("key");
52
- },
53
- async scroll() {
54
- recorded.push("scroll");
55
- },
56
- async getViewport() {
57
- return { width: 800, height: 600, devicePixelRatio: 1 };
58
- },
59
- async disconnect() {
60
- recorded.push("disconnect");
61
- },
62
- };
63
- const d = createDriver({ backend: "chromium", _injected: stub });
64
- await d.connect();
65
- await d.goto("about:blank");
66
- await d.screenshot();
67
- await d.click(100, 200);
68
- await d.type("hi");
69
- await d.key("Enter");
70
- await d.scroll(0, 200);
71
- await d.disconnect();
72
- expect(recorded).toEqual([
73
- "connect",
74
- "goto",
75
- "screenshot",
76
- "click",
77
- "type",
78
- "key",
79
- "scroll",
80
- "disconnect",
81
- ]);
82
- });
83
-
84
- test("chromium driver throws clean diagnostic when used before connect()", async () => {
85
- const d = createDriver({ backend: "chromium" });
86
- await expect(d.screenshot()).rejects.toThrow(/not connected/);
87
- await expect(d.click(0, 0)).rejects.toThrow(/not connected/);
88
- });
89
- });
90
-
91
- describe("host backend stub (createDriver → host)", () => {
92
- const d = createDriver({ backend: "host" });
93
-
94
- test("connect rejects with the kickoff-forbidden diagnostic", async () => {
95
- await expect(d.connect()).rejects.toThrow(/host backend is not implemented in v0/);
96
- });
97
-
98
- test("every operation rejects as not implemented", async () => {
99
- await expect(d.goto("https://x.com")).rejects.toThrow(/host backend not implemented/);
100
- await expect(d.screenshot()).rejects.toThrow(/host backend not implemented/);
101
- await expect(d.click(1, 2)).rejects.toThrow(/host backend not implemented/);
102
- await expect(d.type("x")).rejects.toThrow(/host backend not implemented/);
103
- await expect(d.key("Enter")).rejects.toThrow(/host backend not implemented/);
104
- await expect(d.scroll(0, 0)).rejects.toThrow(/host backend not implemented/);
105
- await expect(d.getViewport()).rejects.toThrow(/host backend not implemented/);
106
- });
107
-
108
- test("disconnect is a silent no-op", async () => {
109
- await expect(d.disconnect()).resolves.toBeUndefined();
110
- });
111
-
112
- test("operation rejections are ComputerUseDriverError instances", async () => {
113
- await expect(d.click(0, 0)).rejects.toBeInstanceOf(ComputerUseDriverError);
114
- });
115
- });
116
-
117
- describe("remote backend stub (createDriver → remote)", () => {
118
- const d = createDriver({ backend: "remote" });
119
-
120
- test("connect rejects (v0 stub)", async () => {
121
- await expect(d.connect()).rejects.toThrow(/remote backend.*not implemented in v0/);
122
- });
123
-
124
- test("every operation rejects as not implemented", async () => {
125
- await expect(d.goto("https://x.com")).rejects.toThrow(/remote backend not implemented/);
126
- await expect(d.screenshot()).rejects.toThrow(/remote backend not implemented/);
127
- await expect(d.click(1, 2)).rejects.toThrow(/remote backend not implemented/);
128
- await expect(d.type("x")).rejects.toThrow(/remote backend not implemented/);
129
- await expect(d.key("Enter")).rejects.toThrow(/remote backend not implemented/);
130
- await expect(d.scroll(0, 0)).rejects.toThrow(/remote backend not implemented/);
131
- await expect(d.getViewport()).rejects.toThrow(/remote backend not implemented/);
132
- });
133
-
134
- test("disconnect is a silent no-op", async () => {
135
- await expect(d.disconnect()).resolves.toBeUndefined();
136
- });
137
-
138
- test("operation rejections are ComputerUseDriverError instances", async () => {
139
- await expect(d.type("x")).rejects.toBeInstanceOf(ComputerUseDriverError);
140
- });
141
- });