@crewhaus/computer-use-driver 0.1.1 → 0.1.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/package.json +7 -12
- package/src/backends/backends.test.ts +230 -2
- package/src/chromium-import-fail.test.ts +39 -0
- package/src/chromium.test.ts +376 -0
- package/src/errors.ts +8 -0
- package/src/index.test.ts +52 -0
- package/src/index.ts +80 -22
- package/src/ssrf-proxy.test.ts +259 -0
- package/src/ssrf-proxy.ts +376 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/computer-use-driver",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cross-platform mouse/keyboard/screenshot driver — chromium backend (Section 25 BROW)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.3"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
18
|
"playwright": "*"
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"license": "Apache-2.0",
|
|
26
26
|
"author": {
|
|
27
27
|
"name": "Max Meier",
|
|
28
|
-
"email": "max@
|
|
29
|
-
"url": "https://
|
|
28
|
+
"email": "max@crewhaus.ai",
|
|
29
|
+
"url": "https://crewhaus.ai"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
@@ -38,12 +38,7 @@
|
|
|
38
38
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
39
39
|
},
|
|
40
40
|
"publishConfig": {
|
|
41
|
-
"access": "
|
|
42
|
-
},
|
|
43
|
-
"files": [
|
|
44
|
-
"src",
|
|
45
|
-
"README.md",
|
|
46
|
-
"LICENSE",
|
|
47
|
-
"NOTICE"
|
|
48
|
-
]
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
49
44
|
}
|
|
@@ -13,9 +13,10 @@ describe("host driver — Section 30", () => {
|
|
|
13
13
|
);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
test("connect +
|
|
16
|
+
test("connect + every operation delegates to the executor", async () => {
|
|
17
17
|
const calls: string[] = [];
|
|
18
18
|
const viewport: Viewport = { width: 1920, height: 1080, devicePixelRatio: 1 };
|
|
19
|
+
let closed = false;
|
|
19
20
|
const executor: HostExecutor = {
|
|
20
21
|
async click(x, y, button) {
|
|
21
22
|
calls.push(`click ${x},${y} ${button}`);
|
|
@@ -34,6 +35,9 @@ describe("host driver — Section 30", () => {
|
|
|
34
35
|
// "iVBORw0KGgo=" decodes to the PNG magic bytes (0x89 0x50 0x4E 0x47 ...).
|
|
35
36
|
return { pngBase64: "iVBORw0KGgo=", viewport };
|
|
36
37
|
},
|
|
38
|
+
async close() {
|
|
39
|
+
closed = true;
|
|
40
|
+
},
|
|
37
41
|
};
|
|
38
42
|
const driver = createHostDriver({ enabled: true, executor });
|
|
39
43
|
await driver.connect();
|
|
@@ -42,7 +46,76 @@ describe("host driver — Section 30", () => {
|
|
|
42
46
|
expect(shot).toBeInstanceOf(Uint8Array);
|
|
43
47
|
expect(shot[0]).toBe(0x89);
|
|
44
48
|
expect(shot[1]).toBe(0x50);
|
|
45
|
-
|
|
49
|
+
await driver.type("hello");
|
|
50
|
+
await driver.key("cmd+space");
|
|
51
|
+
await driver.scroll(5, 6);
|
|
52
|
+
const vp = await driver.getViewport();
|
|
53
|
+
expect(vp).toEqual(viewport);
|
|
54
|
+
await driver.disconnect();
|
|
55
|
+
expect(closed).toBe(true);
|
|
56
|
+
expect(calls).toEqual([
|
|
57
|
+
"click 100,200 left",
|
|
58
|
+
"screenshot",
|
|
59
|
+
"type hello",
|
|
60
|
+
"key cmd+space",
|
|
61
|
+
"scroll 0,0 5,6",
|
|
62
|
+
"screenshot", // getViewport screenshots to read the viewport
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("click defaults to the left button when none is given", async () => {
|
|
67
|
+
const calls: string[] = [];
|
|
68
|
+
const executor: HostExecutor = {
|
|
69
|
+
async click(x, y, button) {
|
|
70
|
+
calls.push(`click ${x},${y} ${button}`);
|
|
71
|
+
},
|
|
72
|
+
async type() {},
|
|
73
|
+
async key() {},
|
|
74
|
+
async scroll() {},
|
|
75
|
+
async screenshot() {
|
|
76
|
+
return { pngBase64: "", viewport: { width: 0, height: 0, devicePixelRatio: 1 } };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const driver = createHostDriver({ enabled: true, executor });
|
|
80
|
+
await driver.connect();
|
|
81
|
+
await driver.click(7, 8);
|
|
82
|
+
expect(calls).toEqual(["click 7,8 left"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("disconnect tolerates an executor without a close() method", async () => {
|
|
86
|
+
const executor: HostExecutor = {
|
|
87
|
+
async click() {},
|
|
88
|
+
async type() {},
|
|
89
|
+
async key() {},
|
|
90
|
+
async scroll() {},
|
|
91
|
+
async screenshot() {
|
|
92
|
+
return { pngBase64: "", viewport: { width: 0, height: 0, devicePixelRatio: 1 } };
|
|
93
|
+
},
|
|
94
|
+
// no close — optional-chaining must not throw
|
|
95
|
+
};
|
|
96
|
+
const driver = createHostDriver({ enabled: true, executor });
|
|
97
|
+
await driver.connect();
|
|
98
|
+
await expect(driver.disconnect()).resolves.toBeUndefined();
|
|
99
|
+
// After disconnect, guarded operations throw again.
|
|
100
|
+
await expect(driver.screenshot()).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("type / key / getViewport reject before connect", async () => {
|
|
104
|
+
const executor: HostExecutor = {
|
|
105
|
+
async click() {},
|
|
106
|
+
async type() {},
|
|
107
|
+
async key() {},
|
|
108
|
+
async scroll() {},
|
|
109
|
+
async screenshot() {
|
|
110
|
+
return { pngBase64: "", viewport: { width: 0, height: 0, devicePixelRatio: 1 } };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const driver = createHostDriver({ enabled: true, executor });
|
|
114
|
+
await expect(driver.type("x")).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
115
|
+
await expect(driver.key("Enter")).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
116
|
+
await expect(driver.scroll(0, 0)).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
117
|
+
await expect(driver.getViewport()).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
118
|
+
await expect(driver.screenshot()).rejects.toBeInstanceOf(ComputerUseDriverError);
|
|
46
119
|
});
|
|
47
120
|
|
|
48
121
|
test("scroll delegates to executor with zero anchor", async () => {
|
|
@@ -165,4 +238,159 @@ describe("remote driver — Section 30", () => {
|
|
|
165
238
|
expect(ops).toContain("click 50,60 right");
|
|
166
239
|
expect(ops).toContain("close-browser");
|
|
167
240
|
});
|
|
241
|
+
|
|
242
|
+
/** Build a fully-stubbed puppeteer-core whose page records into `ops`. */
|
|
243
|
+
function makeStub(ops: string[], opts?: { screenshotAsString?: boolean }) {
|
|
244
|
+
const page = {
|
|
245
|
+
async goto(url: string) {
|
|
246
|
+
ops.push(`goto ${url}`);
|
|
247
|
+
},
|
|
248
|
+
async screenshot(_o?: { encoding?: "base64" }) {
|
|
249
|
+
ops.push("screenshot");
|
|
250
|
+
// "aGk=" is base64 for "hi"; cover both the string and Buffer arms.
|
|
251
|
+
return opts?.screenshotAsString ? "aGk=" : Buffer.from("hi");
|
|
252
|
+
},
|
|
253
|
+
mouse: {
|
|
254
|
+
async click(x: number, y: number, o?: { button?: string }) {
|
|
255
|
+
ops.push(`click ${x},${y} ${o?.button}`);
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
keyboard: {
|
|
259
|
+
async type(text: string) {
|
|
260
|
+
ops.push(`type ${text}`);
|
|
261
|
+
},
|
|
262
|
+
async press(key: string) {
|
|
263
|
+
ops.push(`press ${key}`);
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
// The real driver casts the page and calls evaluate(fn, arg). Run the fn
|
|
267
|
+
// against a stub global exposing scrollBy so the closure body executes.
|
|
268
|
+
async evaluate(fn: (d: { dx: number; dy: number }) => void, arg: { dx: number; dy: number }) {
|
|
269
|
+
const g = globalThis as Record<string, unknown>;
|
|
270
|
+
const prev = g["scrollBy"];
|
|
271
|
+
g["scrollBy"] = (dx: number, dy: number) => {
|
|
272
|
+
ops.push(`scrollBy ${dx},${dy}`);
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
fn(arg);
|
|
276
|
+
} finally {
|
|
277
|
+
g["scrollBy"] = prev;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
async close() {
|
|
281
|
+
ops.push("close-page");
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const browser = {
|
|
285
|
+
async newPage() {
|
|
286
|
+
return page;
|
|
287
|
+
},
|
|
288
|
+
async close() {
|
|
289
|
+
ops.push("close-browser");
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
const puppeteer: PuppeteerCoreLike = {
|
|
293
|
+
async connect(_o) {
|
|
294
|
+
return browser as never;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
return { puppeteer, page, browser };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
test("screenshot decodes a base64 string from puppeteer", async () => {
|
|
301
|
+
const ops: string[] = [];
|
|
302
|
+
const { puppeteer } = makeStub(ops, { screenshotAsString: true });
|
|
303
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
304
|
+
await driver.connect();
|
|
305
|
+
const png = await driver.screenshot();
|
|
306
|
+
expect(png).toBeInstanceOf(Uint8Array);
|
|
307
|
+
expect(Buffer.from(png).toString()).toBe("hi");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("screenshot re-encodes a Buffer from puppeteer", async () => {
|
|
311
|
+
const ops: string[] = [];
|
|
312
|
+
const { puppeteer } = makeStub(ops, { screenshotAsString: false });
|
|
313
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
314
|
+
await driver.connect();
|
|
315
|
+
const png = await driver.screenshot();
|
|
316
|
+
expect(Buffer.from(png).toString()).toBe("hi");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("type / key / scroll delegate through puppeteer", async () => {
|
|
320
|
+
const ops: string[] = [];
|
|
321
|
+
const { puppeteer } = makeStub(ops);
|
|
322
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
323
|
+
await driver.connect();
|
|
324
|
+
await driver.type("hello");
|
|
325
|
+
await driver.key("Enter");
|
|
326
|
+
await driver.scroll(11, 22);
|
|
327
|
+
expect(ops).toContain("type hello");
|
|
328
|
+
expect(ops).toContain("press Enter");
|
|
329
|
+
// The scroll closure runs in the (stubbed) page context and calls scrollBy.
|
|
330
|
+
expect(ops).toContain("scrollBy 11,22");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("click defaults to the left button when none is given", async () => {
|
|
334
|
+
const ops: string[] = [];
|
|
335
|
+
const { puppeteer } = makeStub(ops);
|
|
336
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
337
|
+
await driver.connect();
|
|
338
|
+
await driver.click(1, 2);
|
|
339
|
+
expect(ops).toContain("click 1,2 left");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("getViewport returns the override when provided", async () => {
|
|
343
|
+
const ops: string[] = [];
|
|
344
|
+
const { puppeteer } = makeStub(ops);
|
|
345
|
+
const viewport: Viewport = { width: 640, height: 480, devicePixelRatio: 2 };
|
|
346
|
+
const driver = createRemoteDriver({ url: "ws://t", viewport, _puppeteer: puppeteer });
|
|
347
|
+
await driver.connect();
|
|
348
|
+
expect(await driver.getViewport()).toEqual(viewport);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("getViewport falls back to 1280x800 when no override", async () => {
|
|
352
|
+
const ops: string[] = [];
|
|
353
|
+
const { puppeteer } = makeStub(ops);
|
|
354
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
355
|
+
await driver.connect();
|
|
356
|
+
expect(await driver.getViewport()).toEqual({ width: 1280, height: 800, devicePixelRatio: 1 });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("operations before connect throw 'not connected'", async () => {
|
|
360
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: makeStub([]).puppeteer });
|
|
361
|
+
await expect(driver.goto("https://x.com")).rejects.toThrow(/not connected/);
|
|
362
|
+
await expect(driver.screenshot()).rejects.toThrow(/not connected/);
|
|
363
|
+
await expect(driver.click(0, 0)).rejects.toThrow(/not connected/);
|
|
364
|
+
await expect(driver.type("x")).rejects.toThrow(/not connected/);
|
|
365
|
+
await expect(driver.key("Enter")).rejects.toThrow(/not connected/);
|
|
366
|
+
await expect(driver.scroll(0, 0)).rejects.toThrow(/not connected/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("disconnect closes page then browser and resets state", async () => {
|
|
370
|
+
const ops: string[] = [];
|
|
371
|
+
const { puppeteer } = makeStub(ops);
|
|
372
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
373
|
+
await driver.connect();
|
|
374
|
+
await driver.disconnect();
|
|
375
|
+
expect(ops).toContain("close-page");
|
|
376
|
+
expect(ops).toContain("close-browser");
|
|
377
|
+
// After disconnect the page handle is cleared → operations throw again.
|
|
378
|
+
await expect(driver.click(0, 0)).rejects.toThrow(/not connected/);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("disconnect swallows close() failures (best-effort)", async () => {
|
|
382
|
+
const ops: string[] = [];
|
|
383
|
+
const { puppeteer, page } = makeStub(ops);
|
|
384
|
+
page.close = async () => {
|
|
385
|
+
throw new Error("page close failed");
|
|
386
|
+
};
|
|
387
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: puppeteer });
|
|
388
|
+
await driver.connect();
|
|
389
|
+
await expect(driver.disconnect()).resolves.toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("disconnect before connect is a no-op (no page / browser handles)", async () => {
|
|
393
|
+
const driver = createRemoteDriver({ url: "ws://t", _puppeteer: makeStub([]).puppeteer });
|
|
394
|
+
await expect(driver.disconnect()).resolves.toBeUndefined();
|
|
395
|
+
});
|
|
168
396
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R18 — covers the chromium driver's `loadPlaywright` failure path
|
|
3
|
+
* (the catch arm that rethrows a `ComputerUseDriverError` with an install
|
|
4
|
+
* hint when `import("playwright")` rejects).
|
|
5
|
+
*
|
|
6
|
+
* The failing import is injected via the `_importPlaywright` seam instead of
|
|
7
|
+
* `mock.module("playwright", throwingFactory)`: bun's `mock.module` is
|
|
8
|
+
* process-global, and once "playwright" has been successfully imported
|
|
9
|
+
* anywhere in the test process (chromium.test.ts imports it as a fake), a
|
|
10
|
+
* *throwing* factory is evaluated eagerly and blows up at registration time.
|
|
11
|
+
* Because `bun test` runs every file in one process in nondeterministic
|
|
12
|
+
* order, the module-mock approach worked or exploded depending on which file
|
|
13
|
+
* ran first; the seam is order-independent.
|
|
14
|
+
*/
|
|
15
|
+
import { expect, test } from "bun:test";
|
|
16
|
+
import { ComputerUseDriverError, createDriver } from "./index.js";
|
|
17
|
+
|
|
18
|
+
// Rejects the way a missing optional peer dep does, exercising the same
|
|
19
|
+
// catch arm in `loadPlaywright` as a genuine import failure.
|
|
20
|
+
const failingImport = () => Promise.reject(new Error("Cannot find module 'playwright'"));
|
|
21
|
+
|
|
22
|
+
test("connect surfaces a clean diagnostic when playwright import fails", async () => {
|
|
23
|
+
const d = createDriver({ backend: "chromium", _importPlaywright: failingImport });
|
|
24
|
+
await expect(d.connect()).rejects.toThrow(/Playwright not installed/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("the import error is preserved as the error cause", async () => {
|
|
28
|
+
const d = createDriver({ backend: "chromium", _importPlaywright: failingImport });
|
|
29
|
+
try {
|
|
30
|
+
await d.connect();
|
|
31
|
+
throw new Error("should have thrown");
|
|
32
|
+
} catch (err) {
|
|
33
|
+
expect(err).toBeInstanceOf(ComputerUseDriverError);
|
|
34
|
+
// `cause` is the standard Error.cause carrying the original import error.
|
|
35
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
36
|
+
expect(cause).toBeInstanceOf(Error);
|
|
37
|
+
expect((cause as Error).message).toContain("Cannot find module");
|
|
38
|
+
}
|
|
39
|
+
});
|