@monoes/monobrowse 1.0.5 → 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 +2 -0
- package/dist/src/__tests__/box-model.test.d.ts.map +1 -0
- package/dist/src/__tests__/box-model.test.js +36 -0
- package/dist/src/__tests__/box-model.test.js.map +1 -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 +11 -0
- package/dist/src/browser/snapshot.d.ts.map +1 -1
- package/dist/src/browser/snapshot.js +11 -0
- package/dist/src/browser/snapshot.js.map +1 -1
- 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 +21 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +123 -7
- 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__/box-model.test.ts +39 -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/snapshot.ts +11 -0
- package/src/browser/tabs.ts +12 -4
- package/src/browser/types.ts +4 -0
- package/src/cli/commands.ts +119 -7
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session.ts — the path-safety guards and the state-file round trip.
|
|
3
|
+
*
|
|
4
|
+
* Session files hold cookies and localStorage, so the traversal guards and the
|
|
5
|
+
* 0600/0700 permission bits are security-relevant. All of it is filesystem +
|
|
6
|
+
* validation logic: the CdpClient is a stub that answers the handful of CDP
|
|
7
|
+
* commands the round trip issues.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
10
|
+
import { mkdtemp, rm, readFile, stat, mkdir } from 'fs/promises';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { tmpdir } from 'os';
|
|
13
|
+
import type { CdpClient } from '../browser/cdp.js';
|
|
14
|
+
import {
|
|
15
|
+
saveStateFile,
|
|
16
|
+
loadStateFile,
|
|
17
|
+
saveSession,
|
|
18
|
+
loadSession,
|
|
19
|
+
listSessions,
|
|
20
|
+
} from '../browser/session.js';
|
|
21
|
+
|
|
22
|
+
const COOKIES = [{ name: 'sid', value: 'abc', domain: 'x.test', path: '/' }];
|
|
23
|
+
|
|
24
|
+
/** Records every CDP command and answers the ones session.ts depends on. */
|
|
25
|
+
function stubClient(): { client: CdpClient; calls: Array<{ method: string; params: unknown; sid?: string }> } {
|
|
26
|
+
const calls: Array<{ method: string; params: unknown; sid?: string }> = [];
|
|
27
|
+
const client = {
|
|
28
|
+
send: vi.fn(async (method: string, params: unknown, sid?: string) => {
|
|
29
|
+
calls.push({ method, params, sid });
|
|
30
|
+
if (method === 'Network.getCookies') return { cookies: COOKIES };
|
|
31
|
+
if (method === 'Runtime.evaluate') {
|
|
32
|
+
const expr = (params as { expression: string }).expression;
|
|
33
|
+
if (expr.includes('Object.entries(localStorage)')) return { result: { value: '{"k":"v"}' } };
|
|
34
|
+
if (expr.includes('Object.entries(sessionStorage)')) return { result: { value: '{"s":"1"}' } };
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}),
|
|
38
|
+
} as unknown as CdpClient;
|
|
39
|
+
return { client, calls };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let dir: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
dir = await mkdtemp(join(tmpdir(), 'monobrowse-session-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
await rm(dir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('validateSessionName (via saveSession/loadSession)', () => {
|
|
54
|
+
const { client } = stubClient();
|
|
55
|
+
|
|
56
|
+
// Rejection happens before any filesystem or CDP work, so these never touch
|
|
57
|
+
// the real ~/.monomind directory.
|
|
58
|
+
const bad: Array<[string, string]> = [
|
|
59
|
+
['empty', ''],
|
|
60
|
+
['forward slash', 'a/b'],
|
|
61
|
+
['backslash', 'a\\b'],
|
|
62
|
+
['NUL byte', 'a\x00b'],
|
|
63
|
+
['dot', '.'],
|
|
64
|
+
['dot-dot', '..'],
|
|
65
|
+
['dot-dot prefix', '..evil'],
|
|
66
|
+
['traversal', '../../etc/passwd'],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const [label, name] of bad) {
|
|
70
|
+
it(`rejects ${label}`, async () => {
|
|
71
|
+
await expect(saveSession(client, 'S1', 'T1', name, 'https://x.test', 'T')).rejects.toThrow(
|
|
72
|
+
/Invalid session name/
|
|
73
|
+
);
|
|
74
|
+
await expect(loadSession(client, 'S1', name)).rejects.toThrow(/Invalid session name/);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it('accepts an ordinary name (fails later, on the missing file, not on validation)', async () => {
|
|
79
|
+
await expect(loadSession(client, 'S1', 'no-such-session-xyz')).rejects.toThrow(
|
|
80
|
+
'Session not found: no-such-session-xyz'
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not issue a single CDP command for a rejected name', async () => {
|
|
85
|
+
const { client: c, calls } = stubClient();
|
|
86
|
+
await expect(saveSession(c, 'S1', 'T1', '../x', 'https://x.test', 'T')).rejects.toThrow();
|
|
87
|
+
expect(calls).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('validateFilePath (via saveStateFile/loadStateFile)', () => {
|
|
92
|
+
const { client } = stubClient();
|
|
93
|
+
|
|
94
|
+
const bad: Array<[string, string]> = [
|
|
95
|
+
['empty', ''],
|
|
96
|
+
['NUL byte', '/tmp/a\x00b.json'],
|
|
97
|
+
['embedded ..', '/tmp/a/../../etc/passwd'],
|
|
98
|
+
['leading ../', '../secret.json'],
|
|
99
|
+
['trailing /..', '/tmp/state/..'],
|
|
100
|
+
['backslash', 'C:\\Windows\\state.json'],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const [label, filePath] of bad) {
|
|
104
|
+
it(`rejects ${label}`, async () => {
|
|
105
|
+
await expect(
|
|
106
|
+
saveStateFile(client, 'S1', 'T1', filePath, 'https://x.test', 'T')
|
|
107
|
+
).rejects.toThrow(/Invalid file path/);
|
|
108
|
+
await expect(loadStateFile(client, 'S1', filePath)).rejects.toThrow(/Invalid file path/);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it('allows a plain absolute path', async () => {
|
|
113
|
+
const p = join(dir, 'state.json');
|
|
114
|
+
await expect(saveStateFile(client, 'S1', 'T1', p, 'https://x.test', 'T')).resolves.toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('state file round trip', () => {
|
|
119
|
+
it('captures cookies plus local/session storage and writes owner-only (0600)', async () => {
|
|
120
|
+
const { client, calls } = stubClient();
|
|
121
|
+
const p = join(dir, 'nested', 'state.json');
|
|
122
|
+
await saveStateFile(client, 'S1', 'T1', p, 'https://x.test', 'Page Title');
|
|
123
|
+
|
|
124
|
+
const state = JSON.parse(await readFile(p, 'utf8'));
|
|
125
|
+
expect(state).toEqual({
|
|
126
|
+
targetId: 'T1',
|
|
127
|
+
sessionId: 'S1',
|
|
128
|
+
url: 'https://x.test',
|
|
129
|
+
title: 'Page Title',
|
|
130
|
+
cookies: COOKIES,
|
|
131
|
+
localStorage: { k: 'v' },
|
|
132
|
+
sessionStorage: { s: '1' },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect((await stat(p)).mode & 0o777).toBe(0o600);
|
|
136
|
+
// Parent directory created owner-only too.
|
|
137
|
+
expect((await stat(join(dir, 'nested'))).mode & 0o777).toBe(0o700);
|
|
138
|
+
|
|
139
|
+
// Cookies were scoped to the page URL, not fetched globally.
|
|
140
|
+
expect(calls.find((c) => c.method === 'Network.getCookies')?.params).toEqual({
|
|
141
|
+
urls: ['https://x.test'],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('replays cookies and both storages back into the page on load', async () => {
|
|
146
|
+
const saver = stubClient();
|
|
147
|
+
const p = join(dir, 'state.json');
|
|
148
|
+
await saveStateFile(saver.client, 'S1', 'T1', p, 'https://x.test', 'T');
|
|
149
|
+
|
|
150
|
+
const loader = stubClient();
|
|
151
|
+
const state = await loadStateFile(loader.client, 'S2', p);
|
|
152
|
+
expect(state.cookies).toEqual(COOKIES);
|
|
153
|
+
|
|
154
|
+
const setCookies = loader.calls.find((c) => c.method === 'Network.setCookies');
|
|
155
|
+
expect(setCookies).toMatchObject({ params: { cookies: COOKIES }, sid: 'S2' });
|
|
156
|
+
|
|
157
|
+
const scripts = loader.calls
|
|
158
|
+
.filter((c) => c.method === 'Runtime.evaluate')
|
|
159
|
+
.map((c) => (c.params as { expression: string }).expression);
|
|
160
|
+
expect(scripts).toContain('localStorage.setItem("k", "v")');
|
|
161
|
+
expect(scripts).toContain('sessionStorage.setItem("s", "1")');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects a state file whose cookies field is not an array', async () => {
|
|
165
|
+
const { client } = stubClient();
|
|
166
|
+
const p = join(dir, 'bad.json');
|
|
167
|
+
const { writeFile } = await import('fs/promises');
|
|
168
|
+
await writeFile(p, JSON.stringify({ cookies: { name: 'sid' } }));
|
|
169
|
+
await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
|
|
170
|
+
'Invalid state file: cookies is not an array'
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a state file whose localStorage is an array rather than an object', async () => {
|
|
175
|
+
const { client } = stubClient();
|
|
176
|
+
const p = join(dir, 'bad2.json');
|
|
177
|
+
const { writeFile } = await import('fs/promises');
|
|
178
|
+
await writeFile(p, JSON.stringify({ cookies: [], localStorage: ['nope'] }));
|
|
179
|
+
await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
|
|
180
|
+
'Invalid state file: localStorage is not a plain object'
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('rejects a state file whose sessionStorage is an array', async () => {
|
|
185
|
+
const { client } = stubClient();
|
|
186
|
+
const p = join(dir, 'bad3.json');
|
|
187
|
+
const { writeFile } = await import('fs/promises');
|
|
188
|
+
await writeFile(p, JSON.stringify({ cookies: [], sessionStorage: ['nope'] }));
|
|
189
|
+
await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
|
|
190
|
+
'Invalid state file: sessionStorage is not a plain object'
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('accepts a state file with neither storage map present', async () => {
|
|
195
|
+
const { client, calls } = stubClient();
|
|
196
|
+
const p = join(dir, 'minimal.json');
|
|
197
|
+
const { writeFile } = await import('fs/promises');
|
|
198
|
+
await writeFile(p, JSON.stringify({ targetId: 'T', sessionId: 'S', url: '', title: '', cookies: [] }));
|
|
199
|
+
await expect(loadStateFile(client, 'S1', p)).resolves.toMatchObject({ cookies: [] });
|
|
200
|
+
expect(calls.filter((c) => c.method === 'Runtime.evaluate')).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('listSessions', () => {
|
|
205
|
+
// SESSION_DIR is computed from homedir() at module load, so each case
|
|
206
|
+
// re-imports session.js with 'os' mocked to point at a temp home.
|
|
207
|
+
async function withHome(home: string): Promise<typeof import('../browser/session.js')> {
|
|
208
|
+
vi.resetModules();
|
|
209
|
+
const actualOs = await vi.importActual<typeof import('os')>('os');
|
|
210
|
+
vi.doMock('os', () => ({ ...actualOs, default: actualOs, homedir: () => home }));
|
|
211
|
+
return import('../browser/session.js');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
vi.doUnmock('os');
|
|
216
|
+
vi.resetModules();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns [] when the session directory does not exist', async () => {
|
|
220
|
+
const mod = await withHome(join(dir, 'no-such-home'));
|
|
221
|
+
await expect(mod.listSessions()).resolves.toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('lists only .json files, with the extension stripped', async () => {
|
|
225
|
+
const sessionDir = join(dir, '.monomind', 'browser-sessions');
|
|
226
|
+
await mkdir(sessionDir, { recursive: true });
|
|
227
|
+
const { writeFile } = await import('fs/promises');
|
|
228
|
+
await writeFile(join(sessionDir, 'alpha.json'), '{}');
|
|
229
|
+
await writeFile(join(sessionDir, 'beta.json'), '{}');
|
|
230
|
+
await writeFile(join(sessionDir, 'notes.txt'), 'ignore me');
|
|
231
|
+
|
|
232
|
+
const mod = await withHome(dir);
|
|
233
|
+
await expect(mod.listSessions()).resolves.toEqual(['alpha', 'beta']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('saveSession writes into the session dir and loadSession reads it back', async () => {
|
|
237
|
+
const mod = await withHome(dir);
|
|
238
|
+
const saver = stubClient();
|
|
239
|
+
const filePath = await mod.saveSession(saver.client, 'S1', 'T1', 'my-session', 'https://x.test', 'T');
|
|
240
|
+
expect(filePath).toBe(join(dir, '.monomind', 'browser-sessions', 'my-session.json'));
|
|
241
|
+
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
|
|
242
|
+
expect((await stat(join(dir, '.monomind', 'browser-sessions'))).mode & 0o777).toBe(0o700);
|
|
243
|
+
|
|
244
|
+
const loader = stubClient();
|
|
245
|
+
await expect(mod.loadSession(loader.client, 'S2', 'my-session')).resolves.toMatchObject({
|
|
246
|
+
cookies: COOKIES,
|
|
247
|
+
url: 'https://x.test',
|
|
248
|
+
});
|
|
249
|
+
await expect(mod.listSessions()).resolves.toEqual(['my-session']);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captureSnapshot turns a CDP accessibility tree into the indented ref-tagged
|
|
3
|
+
* text the agent actually reads, and into the ref Map every later command
|
|
4
|
+
* resolves against. That transform is pure — the only browser interaction is
|
|
5
|
+
* the AX-tree fetch, which a stub answers from a literal node array.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import type { CdpClient } from '../browser/cdp.js';
|
|
9
|
+
import { captureSnapshot, resolveRef, getObjectIdForRef, getElementBox } from '../browser/snapshot.js';
|
|
10
|
+
import type { ElementRef } from '../browser/types.js';
|
|
11
|
+
|
|
12
|
+
interface AXProp {
|
|
13
|
+
name: string;
|
|
14
|
+
value: { value: unknown };
|
|
15
|
+
}
|
|
16
|
+
interface AXNode {
|
|
17
|
+
nodeId: number;
|
|
18
|
+
role?: { value: string };
|
|
19
|
+
name?: { value: string };
|
|
20
|
+
description?: { value: string };
|
|
21
|
+
value?: { value: string };
|
|
22
|
+
properties?: AXProp[];
|
|
23
|
+
backendDOMNodeId?: number;
|
|
24
|
+
parentId?: number;
|
|
25
|
+
childIds?: number[];
|
|
26
|
+
ignored?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Client that serves a fixed AX tree plus location.href / document.title. */
|
|
30
|
+
function axClient(
|
|
31
|
+
nodes: AXNode[],
|
|
32
|
+
extra: { url?: string; title?: string; partial?: AXNode[]; selectorNodeId?: number } = {}
|
|
33
|
+
): { client: CdpClient; calls: string[] } {
|
|
34
|
+
const calls: string[] = [];
|
|
35
|
+
const client = {
|
|
36
|
+
send: vi.fn(async (method: string, params: Record<string, unknown>) => {
|
|
37
|
+
calls.push(method);
|
|
38
|
+
switch (method) {
|
|
39
|
+
case 'Accessibility.getFullAXTree':
|
|
40
|
+
return { nodes };
|
|
41
|
+
case 'Accessibility.getPartialAXTree':
|
|
42
|
+
return { nodes: extra.partial ?? nodes };
|
|
43
|
+
case 'DOM.getDocument':
|
|
44
|
+
return { root: { nodeId: 1 } };
|
|
45
|
+
case 'DOM.querySelector':
|
|
46
|
+
return { nodeId: extra.selectorNodeId ?? 0 };
|
|
47
|
+
case 'Runtime.evaluate':
|
|
48
|
+
return {
|
|
49
|
+
result: {
|
|
50
|
+
value:
|
|
51
|
+
(params as { expression: string }).expression === 'location.href'
|
|
52
|
+
? extra.url ?? 'https://x.test/'
|
|
53
|
+
: extra.title ?? 'Test Page',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
default:
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
} as unknown as CdpClient;
|
|
61
|
+
return { client, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const button = (id: number, name: string, over: Partial<AXNode> = {}): AXNode => ({
|
|
65
|
+
nodeId: id,
|
|
66
|
+
role: { value: 'button' },
|
|
67
|
+
name: { value: name },
|
|
68
|
+
backendDOMNodeId: id * 10,
|
|
69
|
+
...over,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('captureSnapshot — tree shape', () => {
|
|
73
|
+
it('emits one indented line per rendered node and a matching ref map', async () => {
|
|
74
|
+
const { client } = axClient([
|
|
75
|
+
{ nodeId: 1, role: { value: 'RootWebArea' }, name: { value: 'Test Page' }, childIds: [2] },
|
|
76
|
+
{ nodeId: 2, role: { value: 'form' }, name: { value: 'Login' }, parentId: 1, childIds: [3] },
|
|
77
|
+
button(3, 'Submit', { parentId: 2 }),
|
|
78
|
+
]);
|
|
79
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
80
|
+
expect(snap.text).toBe(
|
|
81
|
+
'RootWebArea "Test Page" [ref=e1]\n' +
|
|
82
|
+
' form "Login" [ref=e2]\n' +
|
|
83
|
+
' button "Submit" [ref=e3]'
|
|
84
|
+
);
|
|
85
|
+
expect([...snap.refs.keys()]).toEqual(['e1', 'e2', 'e3']);
|
|
86
|
+
expect(snap.refs.get('e3')).toMatchObject({ role: 'button', name: 'Submit', backendDOMNodeId: 30 });
|
|
87
|
+
expect(snap).toMatchObject({ url: 'https://x.test/', title: 'Test Page' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('numbers refs sequentially in document order', async () => {
|
|
91
|
+
const { client } = axClient([
|
|
92
|
+
{ nodeId: 1, role: { value: 'RootWebArea' }, name: { value: '' }, childIds: [2, 3] },
|
|
93
|
+
button(2, 'A', { parentId: 1 }),
|
|
94
|
+
button(3, 'B', { parentId: 1 }),
|
|
95
|
+
]);
|
|
96
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
97
|
+
expect(snap.refs.get('e2')!.name).toBe('A');
|
|
98
|
+
expect(snap.refs.get('e3')!.name).toBe('B');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flattens generic/none/inlineTextBox wrappers without consuming an indent level', async () => {
|
|
102
|
+
const { client } = axClient([
|
|
103
|
+
{ nodeId: 1, role: { value: 'RootWebArea' }, name: { value: '' }, childIds: [2] },
|
|
104
|
+
{ nodeId: 2, role: { value: 'generic' }, parentId: 1, childIds: [3] },
|
|
105
|
+
{ nodeId: 3, role: { value: 'none' }, parentId: 2, childIds: [4] },
|
|
106
|
+
button(4, 'Deep', { parentId: 3 }),
|
|
107
|
+
]);
|
|
108
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
109
|
+
// The button sits one level under the root despite two wrappers between.
|
|
110
|
+
expect(snap.text).toBe('RootWebArea [ref=e1]\n button "Deep" [ref=e2]');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('descends through ignored nodes without emitting or numbering them', async () => {
|
|
114
|
+
const { client } = axClient([
|
|
115
|
+
{ nodeId: 1, role: { value: 'RootWebArea' }, name: { value: '' }, childIds: [2] },
|
|
116
|
+
{ nodeId: 2, ignored: true, role: { value: 'button' }, parentId: 1, childIds: [3] },
|
|
117
|
+
button(3, 'Visible', { parentId: 2 }),
|
|
118
|
+
]);
|
|
119
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
120
|
+
expect([...snap.refs.values()].map((r) => r.name)).toEqual(['', 'Visible']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('handles a forest whose parentId points outside the returned node set', async () => {
|
|
124
|
+
const { client } = axClient([
|
|
125
|
+
button(5, 'Orphan A', { parentId: 999 }),
|
|
126
|
+
button(6, 'Orphan B', { parentId: 998 }),
|
|
127
|
+
]);
|
|
128
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
129
|
+
expect(snap.text.split('\n')).toHaveLength(2);
|
|
130
|
+
expect(snap.text).not.toMatch(/^ /m);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns empty text and no refs for an empty tree', async () => {
|
|
134
|
+
const { client } = axClient([]);
|
|
135
|
+
const snap = await captureSnapshot(client, 'S1');
|
|
136
|
+
expect(snap.text).toBe('');
|
|
137
|
+
expect(snap.refs.size).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('captureSnapshot — options', () => {
|
|
142
|
+
const page: AXNode[] = [
|
|
143
|
+
{ nodeId: 1, role: { value: 'RootWebArea' }, name: { value: 'P' }, childIds: [2] },
|
|
144
|
+
{ nodeId: 2, role: { value: 'main' }, name: { value: '' }, parentId: 1, childIds: [3, 4] },
|
|
145
|
+
button(3, 'Go', { parentId: 2 }),
|
|
146
|
+
{ nodeId: 4, role: { value: 'paragraph' }, name: { value: 'text' }, parentId: 2 },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
it('interactiveOnly keeps interactive roles but still walks past containers', async () => {
|
|
150
|
+
const { client } = axClient(page);
|
|
151
|
+
const snap = await captureSnapshot(client, 'S1', { interactiveOnly: true });
|
|
152
|
+
expect([...snap.refs.values()].map((r) => r.role)).toEqual(['button']);
|
|
153
|
+
// Skipped containers recurse with the SAME depth, so the surviving
|
|
154
|
+
// interactive nodes come out flat rather than indented by the levels of
|
|
155
|
+
// markup that were filtered away.
|
|
156
|
+
expect(snap.text).toBe('button "Go" [ref=e1]');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('compact drops indentation and descriptions', async () => {
|
|
160
|
+
const { client } = axClient([
|
|
161
|
+
button(1, 'Go', { description: { value: 'primary action' } }),
|
|
162
|
+
]);
|
|
163
|
+
const spaced = await captureSnapshot(client, 'S1');
|
|
164
|
+
expect(spaced.text).toBe('button "Go" (primary action) [ref=e1]');
|
|
165
|
+
|
|
166
|
+
const { client: c2 } = axClient([button(1, 'Go', { description: { value: 'primary action' } })]);
|
|
167
|
+
const compact = await captureSnapshot(c2, 'S1', { compact: true });
|
|
168
|
+
expect(compact.text).toBe('button "Go" [ref=e1]');
|
|
169
|
+
// The description is still available on the ref even when not printed.
|
|
170
|
+
expect(compact.refs.get('e1')!.description).toBe('primary action');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('maxDepth prunes nodes below the budget', async () => {
|
|
174
|
+
const { client } = axClient(page);
|
|
175
|
+
const snap = await captureSnapshot(client, 'S1', { maxDepth: 1 });
|
|
176
|
+
expect([...snap.refs.values()].map((r) => r.role)).toEqual(['RootWebArea', 'main']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('a selector scope uses getPartialAXTree when the selector resolves', async () => {
|
|
180
|
+
const { client, calls } = axClient(page, {
|
|
181
|
+
selectorNodeId: 42,
|
|
182
|
+
partial: [button(7, 'Scoped')],
|
|
183
|
+
});
|
|
184
|
+
const snap = await captureSnapshot(client, 'S1', { selector: '#form' });
|
|
185
|
+
expect(calls).toContain('Accessibility.getPartialAXTree');
|
|
186
|
+
expect(calls).not.toContain('Accessibility.getFullAXTree');
|
|
187
|
+
expect(snap.text).toBe('button "Scoped" [ref=e1]');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('a selector that matches nothing falls back to the full tree', async () => {
|
|
191
|
+
const { client, calls } = axClient(page, { selectorNodeId: 0 });
|
|
192
|
+
await captureSnapshot(client, 'S1', { selector: '#nope' });
|
|
193
|
+
expect(calls).toContain('Accessibility.getFullAXTree');
|
|
194
|
+
expect(calls).not.toContain('Accessibility.getPartialAXTree');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('captureSnapshot — property extraction and rendering', () => {
|
|
199
|
+
async function snapOf(props: AXProp[], over: Partial<AXNode> = {}) {
|
|
200
|
+
const { client } = axClient([
|
|
201
|
+
{ nodeId: 1, role: { value: 'textbox' }, name: { value: 'Email' }, properties: props, ...over },
|
|
202
|
+
]);
|
|
203
|
+
return captureSnapshot(client, 'S1');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
it('renders value, placeholder and required', async () => {
|
|
207
|
+
const snap = await snapOf([
|
|
208
|
+
{ name: 'value', value: { value: 'a@b.c' } },
|
|
209
|
+
{ name: 'placeholder', value: { value: 'you@example.com' } },
|
|
210
|
+
{ name: 'required', value: { value: true } },
|
|
211
|
+
]);
|
|
212
|
+
expect(snap.text).toBe(
|
|
213
|
+
'textbox "Email" [ref=e1, value="a@b.c", placeholder="you@example.com", required]'
|
|
214
|
+
);
|
|
215
|
+
expect(snap.refs.get('e1')).toMatchObject({ value: 'a@b.c', placeholder: 'you@example.com' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('renders disabled as a bare flag only when true', async () => {
|
|
219
|
+
expect((await snapOf([{ name: 'disabled', value: { value: true } }])).text).toContain(', disabled]');
|
|
220
|
+
expect((await snapOf([{ name: 'disabled', value: { value: false } }])).text).not.toContain('disabled');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('accepts checked/expanded as either the boolean or the string "true"', async () => {
|
|
224
|
+
const asString = await snapOf([
|
|
225
|
+
{ name: 'checked', value: { value: 'true' } },
|
|
226
|
+
{ name: 'expanded', value: { value: 'true' } },
|
|
227
|
+
]);
|
|
228
|
+
expect(asString.refs.get('e1')).toMatchObject({ checked: true, expanded: true });
|
|
229
|
+
|
|
230
|
+
const asBool = await snapOf([
|
|
231
|
+
{ name: 'checked', value: { value: true } },
|
|
232
|
+
{ name: 'expanded', value: { value: false } },
|
|
233
|
+
]);
|
|
234
|
+
expect(asBool.refs.get('e1')).toMatchObject({ checked: true, expanded: false });
|
|
235
|
+
// false is still rendered — the distinction from "absent" matters.
|
|
236
|
+
expect(asBool.text).toContain('expanded=false');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('treats CDP\'s "mixed" checkbox state as not-checked', async () => {
|
|
240
|
+
const snap = await snapOf([{ name: 'checked', value: { value: 'mixed' } }]);
|
|
241
|
+
expect(snap.refs.get('e1')!.checked).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ignores a null property value rather than stringifying it', async () => {
|
|
245
|
+
const snap = await snapOf([{ name: 'value', value: { value: null } }]);
|
|
246
|
+
expect(snap.refs.get('e1')!.value).toBeUndefined();
|
|
247
|
+
expect(snap.text).not.toContain('value=');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('falls back to node.value when no value property is present', async () => {
|
|
251
|
+
const snap = await snapOf([], { value: { value: 'from-node' } });
|
|
252
|
+
expect(snap.refs.get('e1')!.value).toBe('from-node');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('prefers the value property over node.value', async () => {
|
|
256
|
+
const snap = await snapOf([{ name: 'value', value: { value: 'from-prop' } }], {
|
|
257
|
+
value: { value: 'from-node' },
|
|
258
|
+
});
|
|
259
|
+
expect(snap.refs.get('e1')!.value).toBe('from-prop');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('ignores unrecognized properties', async () => {
|
|
263
|
+
const snap = await snapOf([{ name: 'invalid', value: { value: true } }]);
|
|
264
|
+
expect(snap.text).toBe('textbox "Email" [ref=e1]');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('omits the quoted name entirely when the node is unnamed', async () => {
|
|
268
|
+
const { client } = axClient([{ nodeId: 1, role: { value: 'button' }, name: { value: '' } }]);
|
|
269
|
+
expect((await captureSnapshot(client, 'S1')).text).toBe('button [ref=e1]');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('defaults a role-less node to generic, which flattens it away', async () => {
|
|
273
|
+
const { client } = axClient([{ nodeId: 1, name: { value: 'x' } }]);
|
|
274
|
+
expect((await captureSnapshot(client, 'S1')).text).toBe('');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('resolveRef', () => {
|
|
279
|
+
it('returns the ref when present', () => {
|
|
280
|
+
const refs = new Map<string, ElementRef>([
|
|
281
|
+
['e1', { ref: 'e1', role: 'button', name: 'Go', nodeId: 1 }],
|
|
282
|
+
]);
|
|
283
|
+
expect(resolveRef({} as CdpClient, 'S1', refs, 'e1')).resolves.toMatchObject({ ref: 'e1' });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('throws a snapshot-first hint when the ref is unknown', async () => {
|
|
287
|
+
await expect(resolveRef({} as CdpClient, 'S1', new Map(), 'e9')).rejects.toThrow(
|
|
288
|
+
'Element ref @e9 not found. Run snapshot first.'
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('getObjectIdForRef', () => {
|
|
294
|
+
it('short-circuits to null without a CDP call when there is no backend node id', async () => {
|
|
295
|
+
const send = vi.fn();
|
|
296
|
+
const client = { send } as unknown as CdpClient;
|
|
297
|
+
await expect(
|
|
298
|
+
getObjectIdForRef(client, 'S1', { ref: 'e1', role: 'button', name: '', nodeId: 1 })
|
|
299
|
+
).resolves.toBeNull();
|
|
300
|
+
expect(send).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('resolves via DOM.resolveNode on the backend node id', async () => {
|
|
304
|
+
const send = vi.fn(async () => ({ object: { objectId: 'OBJ' } }));
|
|
305
|
+
const client = { send } as unknown as CdpClient;
|
|
306
|
+
await expect(
|
|
307
|
+
getObjectIdForRef(client, 'S1', { ref: 'e1', role: 'button', name: '', nodeId: 1, backendDOMNodeId: 77 })
|
|
308
|
+
).resolves.toBe('OBJ');
|
|
309
|
+
expect(send.mock.calls[0]).toEqual(['DOM.resolveNode', { backendNodeId: 77 }, 'S1']);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns null when the node no longer resolves to an object', async () => {
|
|
313
|
+
const client = { send: vi.fn(async () => ({ object: {} })) } as unknown as CdpClient;
|
|
314
|
+
await expect(
|
|
315
|
+
getObjectIdForRef(client, 'S1', { ref: 'e1', role: 'button', name: '', nodeId: 1, backendDOMNodeId: 77 })
|
|
316
|
+
).resolves.toBeNull();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('getElementBox', () => {
|
|
321
|
+
it('reports the CENTER of the content quad, per the documented contract', async () => {
|
|
322
|
+
const client = {
|
|
323
|
+
send: vi.fn(async () => ({
|
|
324
|
+
model: { content: [10, 20, 110, 20, 110, 60, 10, 60], width: 100, height: 40 },
|
|
325
|
+
})),
|
|
326
|
+
} as unknown as CdpClient;
|
|
327
|
+
await expect(
|
|
328
|
+
getElementBox(client, 'S1', { ref: 'e1', role: 'button', name: '', nodeId: 1, backendDOMNodeId: 5 })
|
|
329
|
+
).resolves.toEqual({ x: 60, y: 40, width: 100, height: 40 });
|
|
330
|
+
});
|
|
331
|
+
});
|