@monoes/monobrowse 1.0.6 → 1.0.7
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 +68 -0
- package/dist/src/__tests__/adapters.test.d.ts +2 -0
- package/dist/src/__tests__/adapters.test.d.ts.map +1 -0
- package/dist/src/__tests__/adapters.test.js +104 -0
- package/dist/src/__tests__/adapters.test.js.map +1 -0
- package/dist/src/__tests__/batch-eval-parsing.test.d.ts +0 -0
- package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +0 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js +0 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js.map +0 -0
- package/dist/src/__tests__/box-model.test.d.ts +0 -0
- package/dist/src/__tests__/box-model.test.d.ts.map +0 -0
- package/dist/src/__tests__/box-model.test.js +0 -0
- package/dist/src/__tests__/box-model.test.js.map +0 -0
- package/dist/src/__tests__/browser-launch.test.d.ts +0 -0
- package/dist/src/__tests__/browser-launch.test.d.ts.map +0 -0
- package/dist/src/__tests__/browser-launch.test.js +0 -0
- package/dist/src/__tests__/browser-launch.test.js.map +0 -0
- package/dist/src/__tests__/cdp-client.test.d.ts +2 -0
- package/dist/src/__tests__/cdp-client.test.d.ts.map +1 -0
- package/dist/src/__tests__/cdp-client.test.js +326 -0
- package/dist/src/__tests__/cdp-client.test.js.map +1 -0
- package/dist/src/__tests__/close-browser.test.d.ts +2 -0
- package/dist/src/__tests__/close-browser.test.d.ts.map +1 -0
- package/dist/src/__tests__/close-browser.test.js +128 -0
- package/dist/src/__tests__/close-browser.test.js.map +1 -0
- package/dist/src/__tests__/console-log.test.d.ts +2 -0
- package/dist/src/__tests__/console-log.test.d.ts.map +1 -0
- package/dist/src/__tests__/console-log.test.js +195 -0
- package/dist/src/__tests__/console-log.test.js.map +1 -0
- package/dist/src/__tests__/find.test.d.ts +2 -0
- package/dist/src/__tests__/find.test.d.ts.map +1 -0
- package/dist/src/__tests__/find.test.js +299 -0
- package/dist/src/__tests__/find.test.js.map +1 -0
- package/dist/src/__tests__/frame-command.test.d.ts +2 -0
- package/dist/src/__tests__/frame-command.test.d.ts.map +1 -0
- package/dist/src/__tests__/frame-command.test.js +85 -0
- package/dist/src/__tests__/frame-command.test.js.map +1 -0
- package/dist/src/__tests__/output.test.d.ts +0 -0
- package/dist/src/__tests__/output.test.d.ts.map +0 -0
- package/dist/src/__tests__/output.test.js +0 -0
- package/dist/src/__tests__/output.test.js.map +0 -0
- package/dist/src/__tests__/ref-cache.test.d.ts +0 -0
- package/dist/src/__tests__/ref-cache.test.d.ts.map +0 -0
- package/dist/src/__tests__/ref-cache.test.js +43 -2
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/__tests__/session-state.test.d.ts +2 -0
- package/dist/src/__tests__/session-state.test.d.ts.map +1 -0
- package/dist/src/__tests__/session-state.test.js +203 -0
- package/dist/src/__tests__/session-state.test.js.map +1 -0
- package/dist/src/__tests__/snapshot.test.d.ts +2 -0
- package/dist/src/__tests__/snapshot.test.d.ts.map +1 -0
- package/dist/src/__tests__/snapshot.test.js +262 -0
- package/dist/src/__tests__/snapshot.test.js.map +1 -0
- package/dist/src/__tests__/storage-eval.test.d.ts +2 -0
- package/dist/src/__tests__/storage-eval.test.d.ts.map +1 -0
- package/dist/src/__tests__/storage-eval.test.js +133 -0
- package/dist/src/__tests__/storage-eval.test.js.map +1 -0
- package/dist/src/__tests__/tabs.test.d.ts +2 -0
- package/dist/src/__tests__/tabs.test.d.ts.map +1 -0
- package/dist/src/__tests__/tabs.test.js +161 -0
- package/dist/src/__tests__/tabs.test.js.map +1 -0
- package/dist/src/browser/action-builder/analyzer.d.ts +0 -0
- package/dist/src/browser/action-builder/analyzer.d.ts.map +0 -0
- package/dist/src/browser/action-builder/analyzer.js +0 -0
- package/dist/src/browser/action-builder/analyzer.js.map +0 -0
- package/dist/src/browser/action-builder/types.d.ts +0 -0
- package/dist/src/browser/action-builder/types.d.ts.map +0 -0
- package/dist/src/browser/action-builder/types.js +0 -0
- package/dist/src/browser/action-builder/types.js.map +0 -0
- package/dist/src/browser/actions.d.ts +0 -0
- package/dist/src/browser/actions.d.ts.map +0 -0
- package/dist/src/browser/actions.js +0 -0
- package/dist/src/browser/actions.js.map +0 -0
- package/dist/src/browser/adapters/gemini.d.ts +0 -0
- package/dist/src/browser/adapters/gemini.d.ts.map +0 -0
- package/dist/src/browser/adapters/gemini.js +0 -0
- package/dist/src/browser/adapters/gemini.js.map +0 -0
- package/dist/src/browser/adapters/google.d.ts +0 -0
- package/dist/src/browser/adapters/google.d.ts.map +0 -0
- package/dist/src/browser/adapters/google.js +0 -0
- package/dist/src/browser/adapters/google.js.map +0 -0
- package/dist/src/browser/adapters/index.d.ts +0 -0
- package/dist/src/browser/adapters/index.d.ts.map +0 -0
- package/dist/src/browser/adapters/index.js +0 -0
- package/dist/src/browser/adapters/index.js.map +0 -0
- package/dist/src/browser/adapters/instagram.d.ts +0 -0
- package/dist/src/browser/adapters/instagram.d.ts.map +0 -0
- package/dist/src/browser/adapters/instagram.js +0 -0
- package/dist/src/browser/adapters/instagram.js.map +0 -0
- package/dist/src/browser/adapters/linkedin.d.ts +0 -0
- package/dist/src/browser/adapters/linkedin.d.ts.map +0 -0
- package/dist/src/browser/adapters/linkedin.js +0 -0
- package/dist/src/browser/adapters/linkedin.js.map +0 -0
- package/dist/src/browser/adapters/microsoft.d.ts +0 -0
- package/dist/src/browser/adapters/microsoft.d.ts.map +0 -0
- package/dist/src/browser/adapters/microsoft.js +0 -0
- package/dist/src/browser/adapters/microsoft.js.map +0 -0
- package/dist/src/browser/adapters/x.d.ts +0 -0
- package/dist/src/browser/adapters/x.d.ts.map +0 -0
- package/dist/src/browser/adapters/x.js +0 -0
- package/dist/src/browser/adapters/x.js.map +0 -0
- package/dist/src/browser/batch.d.ts +0 -0
- package/dist/src/browser/batch.d.ts.map +0 -0
- package/dist/src/browser/batch.js +0 -0
- package/dist/src/browser/batch.js.map +0 -0
- package/dist/src/browser/browser.d.ts +4 -0
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +55 -7
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts +4 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +45 -16
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/console-log.d.ts +0 -0
- package/dist/src/browser/console-log.d.ts.map +0 -0
- package/dist/src/browser/console-log.js +0 -0
- package/dist/src/browser/console-log.js.map +0 -0
- package/dist/src/browser/dashboard/api-types.d.ts +0 -0
- package/dist/src/browser/dashboard/api-types.d.ts.map +0 -0
- package/dist/src/browser/dashboard/api-types.js +0 -0
- package/dist/src/browser/dashboard/api-types.js.map +0 -0
- package/dist/src/browser/dashboard/server.d.ts +0 -0
- package/dist/src/browser/dashboard/server.d.ts.map +0 -0
- package/dist/src/browser/dashboard/server.js +0 -0
- package/dist/src/browser/dashboard/server.js.map +0 -0
- package/dist/src/browser/dialog.d.ts +0 -0
- package/dist/src/browser/dialog.d.ts.map +0 -0
- package/dist/src/browser/dialog.js +0 -0
- package/dist/src/browser/dialog.js.map +0 -0
- package/dist/src/browser/emulation.d.ts +0 -0
- package/dist/src/browser/emulation.d.ts.map +0 -0
- package/dist/src/browser/emulation.js +0 -0
- package/dist/src/browser/emulation.js.map +0 -0
- package/dist/src/browser/find.d.ts +0 -0
- package/dist/src/browser/find.d.ts.map +0 -0
- package/dist/src/browser/find.js +0 -0
- package/dist/src/browser/find.js.map +0 -0
- package/dist/src/browser/har.d.ts +0 -0
- package/dist/src/browser/har.d.ts.map +0 -0
- package/dist/src/browser/har.js +0 -0
- package/dist/src/browser/har.js.map +0 -0
- package/dist/src/browser/index.d.ts +0 -0
- package/dist/src/browser/index.d.ts.map +0 -0
- package/dist/src/browser/index.js +0 -0
- package/dist/src/browser/index.js.map +0 -0
- package/dist/src/browser/network.d.ts +0 -0
- package/dist/src/browser/network.d.ts.map +0 -0
- package/dist/src/browser/network.js +0 -0
- package/dist/src/browser/network.js.map +0 -0
- package/dist/src/browser/pdf.d.ts +0 -0
- package/dist/src/browser/pdf.d.ts.map +0 -0
- package/dist/src/browser/pdf.js +0 -0
- package/dist/src/browser/pdf.js.map +0 -0
- package/dist/src/browser/profiler.d.ts +0 -0
- package/dist/src/browser/profiler.d.ts.map +0 -0
- package/dist/src/browser/profiler.js +0 -0
- package/dist/src/browser/profiler.js.map +0 -0
- package/dist/src/browser/record.d.ts +0 -0
- package/dist/src/browser/record.d.ts.map +0 -0
- package/dist/src/browser/record.js +0 -0
- package/dist/src/browser/record.js.map +0 -0
- package/dist/src/browser/ref-cache.d.ts +6 -1
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +21 -3
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/screenshot.d.ts +0 -0
- package/dist/src/browser/screenshot.d.ts.map +0 -0
- package/dist/src/browser/screenshot.js +0 -0
- package/dist/src/browser/screenshot.js.map +0 -0
- package/dist/src/browser/session.d.ts +0 -0
- package/dist/src/browser/session.d.ts.map +0 -0
- package/dist/src/browser/session.js +0 -0
- package/dist/src/browser/session.js.map +0 -0
- package/dist/src/browser/snapshot.d.ts +0 -0
- package/dist/src/browser/snapshot.d.ts.map +0 -0
- package/dist/src/browser/snapshot.js +0 -0
- package/dist/src/browser/snapshot.js.map +0 -0
- package/dist/src/browser/storage.d.ts +0 -0
- package/dist/src/browser/storage.d.ts.map +0 -0
- package/dist/src/browser/storage.js +0 -0
- package/dist/src/browser/storage.js.map +0 -0
- package/dist/src/browser/tabs.d.ts +5 -1
- package/dist/src/browser/tabs.d.ts.map +1 -1
- package/dist/src/browser/tabs.js +4 -3
- package/dist/src/browser/tabs.js.map +1 -1
- package/dist/src/browser/trace.d.ts +0 -0
- package/dist/src/browser/trace.d.ts.map +0 -0
- package/dist/src/browser/trace.js +0 -0
- package/dist/src/browser/trace.js.map +0 -0
- package/dist/src/browser/types.d.ts +4 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js +0 -0
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/browser/vitals.d.ts +0 -0
- package/dist/src/browser/vitals.d.ts.map +0 -0
- package/dist/src/browser/vitals.js +0 -0
- package/dist/src/browser/vitals.js.map +0 -0
- package/dist/src/browser/wait.d.ts +0 -0
- package/dist/src/browser/wait.d.ts.map +0 -0
- package/dist/src/browser/wait.js +0 -0
- package/dist/src/browser/wait.js.map +0 -0
- package/dist/src/cli/action.d.ts +0 -0
- package/dist/src/cli/action.d.ts.map +0 -0
- package/dist/src/cli/action.js +0 -0
- package/dist/src/cli/action.js.map +0 -0
- package/dist/src/cli/commands.d.ts +0 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +96 -4
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/output.d.ts +0 -0
- package/dist/src/cli/output.d.ts.map +0 -0
- package/dist/src/cli/output.js +0 -0
- package/dist/src/cli/output.js.map +0 -0
- package/dist/src/cli/platform.d.ts +0 -0
- package/dist/src/cli/platform.d.ts.map +0 -0
- package/dist/src/cli/platform.js +0 -0
- package/dist/src/cli/platform.js.map +0 -0
- package/dist/src/cli/types.d.ts +0 -0
- package/dist/src/cli/types.d.ts.map +0 -0
- package/dist/src/cli/types.js +0 -0
- package/dist/src/cli/types.js.map +0 -0
- package/dist/src/cli.d.ts +0 -0
- package/dist/src/cli.d.ts.map +0 -0
- package/dist/src/cli.js +0 -0
- package/dist/src/cli.js.map +0 -0
- package/dist/src/index.d.ts +0 -0
- package/dist/src/index.d.ts.map +0 -0
- package/dist/src/index.js +0 -0
- package/dist/src/index.js.map +0 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -5
- package/src/__tests__/adapters.test.ts +125 -0
- package/src/__tests__/cdp-client.test.ts +375 -0
- package/src/__tests__/close-browser.test.ts +141 -0
- package/src/__tests__/console-log.test.ts +250 -0
- package/src/__tests__/find.test.ts +354 -0
- package/src/__tests__/frame-command.test.ts +98 -0
- package/src/__tests__/ref-cache.test.ts +47 -2
- package/src/__tests__/session-state.test.ts +251 -0
- package/src/__tests__/snapshot.test.ts +331 -0
- package/src/__tests__/storage-eval.test.ts +171 -0
- package/src/__tests__/tabs.test.ts +183 -0
- package/src/browser/browser.ts +59 -7
- package/src/browser/cdp.ts +52 -15
- package/src/browser/ref-cache.ts +29 -6
- package/src/browser/tabs.ts +12 -4
- package/src/browser/types.ts +4 -0
- package/src/cli/commands.ts +90 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monoes/monobrowse",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Native browser automation via Chrome DevTools Protocol
|
|
3
|
+
"version": "1.0.7",
|
|
4
|
+
"description": "Native browser automation via Chrome DevTools Protocol \u2014 the engine powering monomind browse",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"monobrowse": "./dist/src/cli.js"
|
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
"node": ">=18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"build": "tsc &&
|
|
37
|
+
"build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:coverage": "vitest run --coverage",
|
|
40
|
+
"prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
|
|
38
41
|
"prepublishOnly": "npm run build"
|
|
39
42
|
},
|
|
40
43
|
"keywords": [
|
|
@@ -50,7 +53,7 @@
|
|
|
50
53
|
"email": "nokhodian@gmail.com",
|
|
51
54
|
"url": "https://github.com/nokhodian"
|
|
52
55
|
},
|
|
53
|
-
"license": "
|
|
56
|
+
"license": "Apache-2.0",
|
|
54
57
|
"repository": {
|
|
55
58
|
"type": "git",
|
|
56
59
|
"url": "https://github.com/monoes/monomind.git",
|
|
@@ -61,6 +64,7 @@
|
|
|
61
64
|
"ws": "^8.21.0"
|
|
62
65
|
},
|
|
63
66
|
"devDependencies": {
|
|
64
|
-
"typescript": "^7.0.2"
|
|
67
|
+
"typescript": "^7.0.2",
|
|
68
|
+
"vitest": "^4.1.4"
|
|
65
69
|
}
|
|
66
70
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform adapters are pure declarations plus two tiny page-probing methods.
|
|
3
|
+
* A fake PageInterface (evaluate + url) is all that's needed — no browser.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { adapters, getAdapter, type PageInterface } from '../browser/adapters/index.js';
|
|
7
|
+
|
|
8
|
+
const PLATFORMS = ['linkedin', 'instagram', 'x', 'gemini', 'google', 'microsoft'] as const;
|
|
9
|
+
|
|
10
|
+
function fakePage(opts: { evaluate?: unknown; url?: string } = {}): PageInterface & {
|
|
11
|
+
evaluated: string[];
|
|
12
|
+
} {
|
|
13
|
+
const evaluated: string[] = [];
|
|
14
|
+
return {
|
|
15
|
+
evaluated,
|
|
16
|
+
async evaluate<T>(expression: string): Promise<T> {
|
|
17
|
+
evaluated.push(expression);
|
|
18
|
+
return opts.evaluate as T;
|
|
19
|
+
},
|
|
20
|
+
async url(): Promise<string> {
|
|
21
|
+
return opts.url ?? 'https://example.test/';
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('getAdapter', () => {
|
|
27
|
+
it('returns the adapter whose `platform` matches its registry key', () => {
|
|
28
|
+
for (const key of PLATFORMS) {
|
|
29
|
+
expect(getAdapter(key).platform).toBe(key);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws for an unknown platform, listing the supported ones', () => {
|
|
34
|
+
expect(() => getAdapter('myspace')).toThrow(
|
|
35
|
+
`Unknown platform: myspace. Supported: ${PLATFORMS.join(', ')}`
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is case-sensitive (no silent normalization)', () => {
|
|
40
|
+
expect(() => getAdapter('LinkedIn')).toThrow(/Unknown platform/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('registers exactly the documented platform set', () => {
|
|
44
|
+
expect([...adapters.keys()]).toEqual([...PLATFORMS]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('adapter invariants', () => {
|
|
49
|
+
for (const key of PLATFORMS) {
|
|
50
|
+
describe(key, () => {
|
|
51
|
+
const a = getAdapter(key);
|
|
52
|
+
|
|
53
|
+
it('exposes an https baseURL with no trailing slash', () => {
|
|
54
|
+
expect(a.baseURL).toMatch(/^https:\/\//);
|
|
55
|
+
expect(a.baseURL.endsWith('/')).toBe(false);
|
|
56
|
+
expect(() => new URL(a.baseURL)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('exposes a parseable https loginURL', () => {
|
|
60
|
+
const login = a.loginURL();
|
|
61
|
+
expect(login).toMatch(/^https:\/\//);
|
|
62
|
+
expect(() => new URL(login)).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('lists reservedPaths as non-empty root-anchored paths', () => {
|
|
66
|
+
expect(a.reservedPaths.length).toBeGreaterThan(0);
|
|
67
|
+
for (const p of a.reservedPaths) expect(p.startsWith('/')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('isLoggedIn', () => {
|
|
74
|
+
it('coerces the page probe to the boolean the caller expects', async () => {
|
|
75
|
+
await expect(getAdapter('linkedin').isLoggedIn(fakePage({ evaluate: true }))).resolves.toBe(true);
|
|
76
|
+
await expect(getAdapter('linkedin').isLoggedIn(fakePage({ evaluate: false }))).resolves.toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('google short-circuits to false on the sign-in page without probing the DOM', async () => {
|
|
80
|
+
const page = fakePage({ url: 'https://accounts.google.com/signin/v2/identifier', evaluate: true });
|
|
81
|
+
await expect(getAdapter('google').isLoggedIn(page)).resolves.toBe(false);
|
|
82
|
+
expect(page.evaluated).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('google probes the DOM once past the sign-in page', async () => {
|
|
86
|
+
const page = fakePage({ url: 'https://mail.google.com/mail/u/0/', evaluate: true });
|
|
87
|
+
await expect(getAdapter('google').isLoggedIn(page)).resolves.toBe(true);
|
|
88
|
+
expect(page.evaluated).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('microsoft short-circuits on both login hostnames', async () => {
|
|
92
|
+
for (const url of [
|
|
93
|
+
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
94
|
+
'https://login.microsoft.com/whatever',
|
|
95
|
+
]) {
|
|
96
|
+
const page = fakePage({ url, evaluate: true });
|
|
97
|
+
await expect(getAdapter('microsoft').isLoggedIn(page)).resolves.toBe(false);
|
|
98
|
+
expect(page.evaluated).toEqual([]);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('microsoft probes the DOM elsewhere', async () => {
|
|
103
|
+
const page = fakePage({ url: 'https://teams.microsoft.com/', evaluate: true });
|
|
104
|
+
await expect(getAdapter('microsoft').isLoggedIn(page)).resolves.toBe(true);
|
|
105
|
+
expect(page.evaluated).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('extractUsername', () => {
|
|
110
|
+
it('returns whatever the page evaluation yields', async () => {
|
|
111
|
+
for (const key of PLATFORMS) {
|
|
112
|
+
await expect(getAdapter(key).extractUsername(fakePage({ evaluate: 'someuser' }))).resolves.toBe(
|
|
113
|
+
'someuser'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('propagates evaluation failures so callers can apply their own fallback', async () => {
|
|
119
|
+
const page: PageInterface = {
|
|
120
|
+
evaluate: vi.fn().mockRejectedValue(new Error('detached frame')),
|
|
121
|
+
url: async () => '',
|
|
122
|
+
};
|
|
123
|
+
await expect(getAdapter('x').extractUsername(page)).rejects.toThrow('detached frame');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CdpClient dispatch/guard logic and the two HTTP endpoint helpers.
|
|
3
|
+
*
|
|
4
|
+
* No browser and no WebSocket server: `connect()` is exercised against a fake
|
|
5
|
+
* `ws` module (so we can drive 'message'/'close'/'error' by hand), and
|
|
6
|
+
* fetchTargets/fetchNewTarget run against a stubbed global fetch.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
// --- fake `ws` -------------------------------------------------------------
|
|
11
|
+
// Captures the most recently constructed socket so tests can drive its events.
|
|
12
|
+
let lastSocket: FakeWs | null = null;
|
|
13
|
+
|
|
14
|
+
class FakeWs {
|
|
15
|
+
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
16
|
+
sent: string[] = [];
|
|
17
|
+
closed = false;
|
|
18
|
+
/** When set, the `send` callback receives this error instead of success. */
|
|
19
|
+
sendError: Error | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(public url: string) {
|
|
22
|
+
lastSocket = this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
on(event: string, fn: (...a: unknown[]) => void): void {
|
|
26
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
27
|
+
this.handlers.get(event)!.push(fn);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
emit(event: string, ...args: unknown[]): void {
|
|
31
|
+
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
send(data: string, cb?: (err?: Error) => void): void {
|
|
35
|
+
this.sent.push(data);
|
|
36
|
+
cb?.(this.sendError ?? undefined);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
close(): void {
|
|
40
|
+
this.closed = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Convenience: deliver a CDP frame as the ws 'message' event does. */
|
|
44
|
+
deliver(msg: unknown): void {
|
|
45
|
+
this.emit('message', Buffer.from(JSON.stringify(msg)));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
vi.mock('ws', () => ({ WebSocket: FakeWs }));
|
|
50
|
+
|
|
51
|
+
const { CdpClient, fetchTargets, fetchNewTarget } = await import('../browser/cdp.js');
|
|
52
|
+
type Client = InstanceType<typeof CdpClient>;
|
|
53
|
+
|
|
54
|
+
/** Build a connected client plus its fake socket. */
|
|
55
|
+
async function connected(): Promise<{ client: Client; ws: FakeWs }> {
|
|
56
|
+
const client = new CdpClient();
|
|
57
|
+
const p = client.connect('ws://127.0.0.1:9222/devtools/page/ABC');
|
|
58
|
+
lastSocket!.emit('open');
|
|
59
|
+
await p;
|
|
60
|
+
return { client, ws: lastSocket! };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
lastSocket = null;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('connect', () => {
|
|
68
|
+
it('resolves on open and reports connected', async () => {
|
|
69
|
+
const { client, ws } = await connected();
|
|
70
|
+
expect(ws.url).toBe('ws://127.0.0.1:9222/devtools/page/ABC');
|
|
71
|
+
expect(client.isConnected()).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects when the socket errors before ever opening', async () => {
|
|
75
|
+
const client = new CdpClient();
|
|
76
|
+
const p = client.connect('ws://127.0.0.1:1/devtools');
|
|
77
|
+
lastSocket!.emit('error', new Error('ECONNREFUSED'));
|
|
78
|
+
await expect(p).rejects.toThrow('ECONNREFUSED');
|
|
79
|
+
expect(client.isConnected()).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('send', () => {
|
|
84
|
+
it('rejects when not connected rather than queueing forever', async () => {
|
|
85
|
+
await expect(new CdpClient().send('Page.enable')).rejects.toThrow('CDP not connected');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('serializes id/method/params and omits sessionId when not given', async () => {
|
|
89
|
+
const { client, ws } = await connected();
|
|
90
|
+
void client.send('Page.navigate', { url: 'https://x.test' });
|
|
91
|
+
expect(JSON.parse(ws.sent[0]!)).toEqual({
|
|
92
|
+
id: 1,
|
|
93
|
+
method: 'Page.navigate',
|
|
94
|
+
params: { url: 'https://x.test' },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('attaches sessionId when supplied and increments ids monotonically', async () => {
|
|
99
|
+
const { client, ws } = await connected();
|
|
100
|
+
void client.send('A', {}, 'S1');
|
|
101
|
+
void client.send('B', {}, 'S1');
|
|
102
|
+
expect(JSON.parse(ws.sent[0]!).id).toBe(1);
|
|
103
|
+
expect(JSON.parse(ws.sent[1]!)).toMatchObject({ id: 2, method: 'B', sessionId: 'S1' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('resolves with the response result matched by id', async () => {
|
|
107
|
+
const { client, ws } = await connected();
|
|
108
|
+
const a = client.send<{ v: string }>('A');
|
|
109
|
+
const b = client.send<{ v: string }>('B');
|
|
110
|
+
// Answer out of order to prove id matching, not FIFO.
|
|
111
|
+
ws.deliver({ id: 2, result: { v: 'second' } });
|
|
112
|
+
ws.deliver({ id: 1, result: { v: 'first' } });
|
|
113
|
+
expect(await a).toEqual({ v: 'first' });
|
|
114
|
+
expect(await b).toEqual({ v: 'second' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('resolves with {} when the response carries no result', async () => {
|
|
118
|
+
const { client, ws } = await connected();
|
|
119
|
+
const p = client.send('A');
|
|
120
|
+
ws.deliver({ id: 1 });
|
|
121
|
+
expect(await p).toEqual({});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects with the CDP error code and message', async () => {
|
|
125
|
+
const { client, ws } = await connected();
|
|
126
|
+
const p = client.send('Bad.method');
|
|
127
|
+
ws.deliver({ id: 1, error: { code: -32601, message: 'method not found' } });
|
|
128
|
+
await expect(p).rejects.toThrow('CDP error -32601: method not found');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects and forgets the command when the socket send callback errors', async () => {
|
|
132
|
+
const { client, ws } = await connected();
|
|
133
|
+
ws.sendError = new Error('socket write failed');
|
|
134
|
+
await expect(client.send('A')).rejects.toThrow('socket write failed');
|
|
135
|
+
// Not left pending: a later close() must not try to reject it a second time.
|
|
136
|
+
ws.sendError = null;
|
|
137
|
+
expect(() => client.close()).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('refuses to exceed the 1000 in-flight command cap', async () => {
|
|
141
|
+
const { client } = await connected();
|
|
142
|
+
const inflight = Array.from({ length: 1000 }, () => client.send('A').catch(() => {}));
|
|
143
|
+
await expect(client.send('A')).rejects.toThrow('CDP command queue full (>1000 in-flight commands)');
|
|
144
|
+
client.close();
|
|
145
|
+
await Promise.all(inflight);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('#115: default timeout (a response that never arrives must not hang forever)', () => {
|
|
149
|
+
beforeEach(() => vi.useFakeTimers());
|
|
150
|
+
afterEach(() => vi.useRealTimers());
|
|
151
|
+
|
|
152
|
+
it('rejects a command with no response after the default 30s timeout', async () => {
|
|
153
|
+
const { client } = await connected();
|
|
154
|
+
const p = client.send('Runtime.evaluate');
|
|
155
|
+
const assertion = expect(p).rejects.toThrow('CDP command "Runtime.evaluate" timed out after 30000ms');
|
|
156
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
157
|
+
await assertion;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('honors a caller-supplied timeout shorter than the default', async () => {
|
|
161
|
+
const { client } = await connected();
|
|
162
|
+
const p = client.send('Runtime.evaluate', {}, undefined, 500);
|
|
163
|
+
const assertion = expect(p).rejects.toThrow('timed out after 500ms');
|
|
164
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
165
|
+
await assertion;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('timeoutMs: 0 disables the timeout entirely', async () => {
|
|
169
|
+
const { client, ws } = await connected();
|
|
170
|
+
const p = client.send('A', {}, undefined, 0);
|
|
171
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
172
|
+
// Still pending — no timeout fired.
|
|
173
|
+
let settled = false;
|
|
174
|
+
void p.then(() => { settled = true; }).catch(() => { settled = true; });
|
|
175
|
+
await Promise.resolve();
|
|
176
|
+
expect(settled).toBe(false);
|
|
177
|
+
ws.deliver({ id: 1, result: {} });
|
|
178
|
+
await expect(p).resolves.toEqual({});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('a response arriving before the timeout clears the timer (does not also reject later)', async () => {
|
|
182
|
+
const { client, ws } = await connected();
|
|
183
|
+
const p = client.send('A');
|
|
184
|
+
ws.deliver({ id: 1, result: { ok: true } });
|
|
185
|
+
await expect(p).resolves.toEqual({ ok: true });
|
|
186
|
+
// Advancing past the timeout window must not throw/reject anything further.
|
|
187
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('#115: Target.targetCrashed / targetDestroyed flush pending commands', () => {
|
|
192
|
+
it('targetCrashed rejects in-flight commands instead of leaving them pending', async () => {
|
|
193
|
+
const { client, ws } = await connected();
|
|
194
|
+
const p = client.send('Runtime.evaluate');
|
|
195
|
+
ws.deliver({ method: 'Target.targetCrashed', params: {} });
|
|
196
|
+
await expect(p).rejects.toThrow('CDP target crashed');
|
|
197
|
+
expect(client.isConnected()).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('targetDestroyed rejects in-flight commands', async () => {
|
|
201
|
+
const { client, ws } = await connected();
|
|
202
|
+
const p = client.send('Runtime.evaluate');
|
|
203
|
+
ws.deliver({ method: 'Target.targetDestroyed', params: {} });
|
|
204
|
+
await expect(p).rejects.toThrow('CDP target destroyed');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('event dispatch', () => {
|
|
210
|
+
it('routes events to listeners with params and sessionId', async () => {
|
|
211
|
+
const { client, ws } = await connected();
|
|
212
|
+
const seen: Array<[Record<string, unknown>, string | undefined]> = [];
|
|
213
|
+
client.on('Page.loadEventFired', (params, sid) => seen.push([params, sid]));
|
|
214
|
+
ws.deliver({ method: 'Page.loadEventFired', params: { timestamp: 5 }, sessionId: 'S1' });
|
|
215
|
+
expect(seen).toEqual([[{ timestamp: 5 }, 'S1']]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('passes {} when the event has no params', async () => {
|
|
219
|
+
const { client, ws } = await connected();
|
|
220
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
221
|
+
client.on('E', (p) => seen.push(p));
|
|
222
|
+
ws.deliver({ method: 'E' });
|
|
223
|
+
expect(seen).toEqual([{}]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('the returned off() unsubscribes', async () => {
|
|
227
|
+
const { client, ws } = await connected();
|
|
228
|
+
let count = 0;
|
|
229
|
+
const off = client.on('E', () => count++);
|
|
230
|
+
ws.deliver({ method: 'E' });
|
|
231
|
+
off();
|
|
232
|
+
ws.deliver({ method: 'E' });
|
|
233
|
+
expect(count).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('isolates a throwing listener so siblings still run', async () => {
|
|
237
|
+
const { client, ws } = await connected();
|
|
238
|
+
let reached = false;
|
|
239
|
+
client.on('E', () => {
|
|
240
|
+
throw new Error('listener blew up');
|
|
241
|
+
});
|
|
242
|
+
client.on('E', () => {
|
|
243
|
+
reached = true;
|
|
244
|
+
});
|
|
245
|
+
expect(() => ws.deliver({ method: 'E' })).not.toThrow();
|
|
246
|
+
expect(reached).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('swallows malformed frames instead of crashing the socket', async () => {
|
|
250
|
+
const { client, ws } = await connected();
|
|
251
|
+
expect(() => ws.emit('message', Buffer.from('{not json'))).not.toThrow();
|
|
252
|
+
expect(client.isConnected()).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('caps listeners per event at 100', async () => {
|
|
256
|
+
const { client } = await connected();
|
|
257
|
+
for (let i = 0; i < 100; i++) client.on('E', () => {});
|
|
258
|
+
expect(() => client.on('E', () => {})).toThrow('CDP event listener limit reached for event: E');
|
|
259
|
+
// The cap is per event name, not global.
|
|
260
|
+
expect(() => client.on('OtherEvent', () => {})).not.toThrow();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('once / onceWithOff', () => {
|
|
265
|
+
it('resolves on the first matching event and self-unsubscribes', async () => {
|
|
266
|
+
const { client, ws } = await connected();
|
|
267
|
+
const p = client.once('E');
|
|
268
|
+
ws.deliver({ method: 'E', params: { n: 1 } });
|
|
269
|
+
ws.deliver({ method: 'E', params: { n: 2 } });
|
|
270
|
+
expect(await p).toEqual({ n: 1 });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('filters by sessionId when one is given', async () => {
|
|
274
|
+
const { client, ws } = await connected();
|
|
275
|
+
const p = client.once('E', 'S1');
|
|
276
|
+
ws.deliver({ method: 'E', params: { from: 'S2' }, sessionId: 'S2' });
|
|
277
|
+
ws.deliver({ method: 'E', params: { from: 'S1' }, sessionId: 'S1' });
|
|
278
|
+
expect(await p).toEqual({ from: 'S1' });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('onceWithOff returns a canceller that stops the waiter from firing', async () => {
|
|
282
|
+
const { client, ws } = await connected();
|
|
283
|
+
let settled = false;
|
|
284
|
+
const [promise, off] = client.onceWithOff('E');
|
|
285
|
+
void promise.then(() => {
|
|
286
|
+
settled = true;
|
|
287
|
+
});
|
|
288
|
+
off();
|
|
289
|
+
ws.deliver({ method: 'E' });
|
|
290
|
+
await Promise.resolve();
|
|
291
|
+
expect(settled).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('teardown paths', () => {
|
|
296
|
+
it('close() rejects every pending command, drops listeners and closes the socket', async () => {
|
|
297
|
+
const { client, ws } = await connected();
|
|
298
|
+
const pending = client.send('A');
|
|
299
|
+
let events = 0;
|
|
300
|
+
client.on('E', () => events++);
|
|
301
|
+
|
|
302
|
+
client.close();
|
|
303
|
+
|
|
304
|
+
await expect(pending).rejects.toThrow('CDP connection closed');
|
|
305
|
+
expect(ws.closed).toBe(true);
|
|
306
|
+
expect(client.isConnected()).toBe(false);
|
|
307
|
+
ws.deliver({ method: 'E' });
|
|
308
|
+
expect(events).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('a socket "close" event rejects pending commands', async () => {
|
|
312
|
+
const { client, ws } = await connected();
|
|
313
|
+
const pending = client.send('A');
|
|
314
|
+
ws.emit('close');
|
|
315
|
+
await expect(pending).rejects.toThrow('CDP connection closed');
|
|
316
|
+
expect(client.isConnected()).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('a post-connect "error" flushes pending commands with that error', async () => {
|
|
320
|
+
const { client, ws } = await connected();
|
|
321
|
+
const pending = client.send('A');
|
|
322
|
+
ws.emit('error', new Error('EPIPE'));
|
|
323
|
+
await expect(pending).rejects.toThrow('EPIPE');
|
|
324
|
+
expect(client.isConnected()).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('fetchTargets / fetchNewTarget', () => {
|
|
329
|
+
const realFetch = globalThis.fetch;
|
|
330
|
+
afterEach(() => {
|
|
331
|
+
globalThis.fetch = realFetch;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
function stubFetch(impl: (url: string, init?: RequestInit) => Response): ReturnType<typeof vi.fn> {
|
|
335
|
+
const fn = vi.fn((url: string, init?: RequestInit) => Promise.resolve(impl(url, init)));
|
|
336
|
+
globalThis.fetch = fn as unknown as typeof fetch;
|
|
337
|
+
return fn;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
it('fetchTargets hits /json/list on the given port', async () => {
|
|
341
|
+
const fn = stubFetch(() => new Response(JSON.stringify([{ id: 'T1', type: 'page' }]), { status: 200 }));
|
|
342
|
+
await expect(fetchTargets(9333)).resolves.toEqual([{ id: 'T1', type: 'page' }]);
|
|
343
|
+
expect(fn.mock.calls[0]![0]).toBe('http://127.0.0.1:9333/json/list');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('fetchTargets throws on a non-OK response', async () => {
|
|
347
|
+
stubFetch(() => new Response('nope', { status: 500, statusText: 'Internal Server Error' }));
|
|
348
|
+
await expect(fetchTargets(9222)).rejects.toThrow('Failed to fetch targets: Internal Server Error');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('fetchNewTarget uses PUT and percent-encodes the URL', async () => {
|
|
352
|
+
const fn = stubFetch(() => new Response(JSON.stringify({ id: 'T2' }), { status: 200 }));
|
|
353
|
+
await expect(fetchNewTarget(9222, 'https://x.test/a?b=1&c=2')).resolves.toEqual({ id: 'T2' });
|
|
354
|
+
const [url, init] = fn.mock.calls[0] as [string, RequestInit];
|
|
355
|
+
expect(init.method).toBe('PUT');
|
|
356
|
+
expect(url).toBe(
|
|
357
|
+
'http://127.0.0.1:9222/json/new?' + encodeURIComponent('https://x.test/a?b=1&c=2')
|
|
358
|
+
);
|
|
359
|
+
// The query separators of the target URL must not leak into Chrome's own query.
|
|
360
|
+
expect(url.split('?').length).toBe(2);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('fetchNewTarget throws on a non-OK response', async () => {
|
|
364
|
+
stubFetch(() => new Response('', { status: 405, statusText: 'Method Not Allowed' }));
|
|
365
|
+
await expect(fetchNewTarget(9222, 'about:blank')).rejects.toThrow(
|
|
366
|
+
'Failed to create target: Method Not Allowed'
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('rejects responses over the 10 MB body cap instead of parsing them', async () => {
|
|
371
|
+
const huge = JSON.stringify([{ pad: 'x'.repeat(11 * 1024 * 1024) }]);
|
|
372
|
+
stubFetch(() => new Response(huge, { status: 200 }));
|
|
373
|
+
await expect(fetchTargets(9222)).rejects.toThrow(/CDP response too large: \d+ bytes/);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* closeBrowser()'s cross-process PID-kill fallback (browser.ts) — previously
|
|
3
|
+
* had zero test coverage anywhere in this package (found in a review pass
|
|
4
|
+
* after commit c4847e22). Covers:
|
|
5
|
+
* - using a persisted PID when the in-process launchedPids map is empty
|
|
6
|
+
* (the always-true case for a fresh CLI invocation)
|
|
7
|
+
* - rejecting a persisted PID that is too old to trust (staleness bound)
|
|
8
|
+
* - a liveness check (process.kill(pid, 0)) gating the SIGKILL itself
|
|
9
|
+
*
|
|
10
|
+
* No real Chrome or child process is spawned: Browser.close is driven via a
|
|
11
|
+
* fake `ws` socket (same pattern as cdp-client.test.ts), and process.kill is
|
|
12
|
+
* spied on rather than actually signaling anything.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
|
|
19
|
+
let lastSocket: FakeWs | null = null;
|
|
20
|
+
|
|
21
|
+
class FakeWs {
|
|
22
|
+
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
23
|
+
constructor(public url: string) {
|
|
24
|
+
lastSocket = this;
|
|
25
|
+
}
|
|
26
|
+
on(event: string, fn: (...a: unknown[]) => void): void {
|
|
27
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
28
|
+
this.handlers.get(event)!.push(fn);
|
|
29
|
+
}
|
|
30
|
+
emit(event: string, ...args: unknown[]): void {
|
|
31
|
+
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
|
|
32
|
+
}
|
|
33
|
+
send(_data: string, cb?: (err?: Error) => void): void {
|
|
34
|
+
// Never call cb — simulates a hung Browser.close so closeBrowser's own
|
|
35
|
+
// BROWSER_CLOSE_TIMEOUT_MS race is what settles it (ungraceful path).
|
|
36
|
+
void cb;
|
|
37
|
+
}
|
|
38
|
+
close(): void {}
|
|
39
|
+
deliver(msg: unknown): void {
|
|
40
|
+
this.emit('message', Buffer.from(JSON.stringify(msg)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
vi.mock('ws', () => ({ WebSocket: FakeWs }));
|
|
45
|
+
|
|
46
|
+
let tempDir: string;
|
|
47
|
+
let killSpy: ReturnType<typeof vi.spyOn>;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
vi.useFakeTimers();
|
|
51
|
+
lastSocket = null;
|
|
52
|
+
tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-close-test-'));
|
|
53
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
|
|
54
|
+
killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function writePersistedPort(port: number, pid: number, savedAt: number): Promise<void> {
|
|
64
|
+
const dir = join(tempDir, '.monomind', 'monobrowse');
|
|
65
|
+
await mkdir(dir, { recursive: true });
|
|
66
|
+
await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port, pid, launched: true, savedAt }), 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function connectedClient() {
|
|
70
|
+
vi.resetModules();
|
|
71
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
72
|
+
const client = new CdpClient();
|
|
73
|
+
const p = client.connect('ws://127.0.0.1:9333/devtools/page/ABC');
|
|
74
|
+
lastSocket!.emit('open');
|
|
75
|
+
await p;
|
|
76
|
+
return client;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('#115 review follow-up: closeBrowser() cross-process PID-kill fallback', () => {
|
|
80
|
+
it('uses a fresh persisted PID (in-process map empty) and SIGKILLs it on an ungraceful close', async () => {
|
|
81
|
+
vi.resetModules();
|
|
82
|
+
const { closeBrowser, getLaunchedPid } = await import('../browser/browser.js');
|
|
83
|
+
const port = 9333;
|
|
84
|
+
await writePersistedPort(port, 54321, Date.now());
|
|
85
|
+
// Confirm the in-process map really is empty for this port (fresh module state).
|
|
86
|
+
expect(getLaunchedPid(port)).toBeUndefined();
|
|
87
|
+
|
|
88
|
+
const client = await connectedClient();
|
|
89
|
+
const closePromise = closeBrowser(client, port);
|
|
90
|
+
await vi.advanceTimersByTimeAsync(3000); // BROWSER_CLOSE_TIMEOUT_MS
|
|
91
|
+
await closePromise;
|
|
92
|
+
|
|
93
|
+
expect(killSpy).toHaveBeenCalledWith(54321, 0);
|
|
94
|
+
expect(killSpy).toHaveBeenCalledWith(54321, 'SIGKILL');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('#124-review: does not trust a persisted PID older than the staleness bound', async () => {
|
|
98
|
+
vi.resetModules();
|
|
99
|
+
const { closeBrowser } = await import('../browser/browser.js');
|
|
100
|
+
const port = 9334;
|
|
101
|
+
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000;
|
|
102
|
+
await writePersistedPort(port, 99999, twoDaysAgo);
|
|
103
|
+
|
|
104
|
+
const client = await connectedClient();
|
|
105
|
+
const closePromise = closeBrowser(client, port);
|
|
106
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
107
|
+
await closePromise;
|
|
108
|
+
|
|
109
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('#124-review: a persisted PID that is no longer alive is not force-killed (liveness check via kill(pid,0))', async () => {
|
|
113
|
+
vi.resetModules();
|
|
114
|
+
const { closeBrowser } = await import('../browser/browser.js');
|
|
115
|
+
const port = 9335;
|
|
116
|
+
await writePersistedPort(port, 11111, Date.now());
|
|
117
|
+
killSpy.mockImplementation((_pid: unknown, signal?: unknown) => {
|
|
118
|
+
if (signal === 0) throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' });
|
|
119
|
+
return true as never;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const client = await connectedClient();
|
|
123
|
+
const closePromise = closeBrowser(client, port);
|
|
124
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
125
|
+
await closePromise;
|
|
126
|
+
|
|
127
|
+
expect(killSpy).toHaveBeenCalledWith(11111, 0);
|
|
128
|
+
expect(killSpy).not.toHaveBeenCalledWith(11111, 'SIGKILL');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('no persisted port file at all — closeBrowser is a no-op, no kill attempted', async () => {
|
|
132
|
+
vi.resetModules();
|
|
133
|
+
const { closeBrowser } = await import('../browser/browser.js');
|
|
134
|
+
const client = await connectedClient();
|
|
135
|
+
const closePromise = closeBrowser(client, 9336);
|
|
136
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
137
|
+
await closePromise;
|
|
138
|
+
|
|
139
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
});
|