@moxxy/plugin-browser 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser-session.d.ts +43 -0
  3. package/dist/browser-session.d.ts.map +1 -0
  4. package/dist/browser-session.js +500 -0
  5. package/dist/browser-session.js.map +1 -0
  6. package/dist/browser-surface.d.ts +3 -0
  7. package/dist/browser-surface.d.ts.map +1 -0
  8. package/dist/browser-surface.js +255 -0
  9. package/dist/browser-surface.js.map +1 -0
  10. package/dist/html-extract.d.ts +20 -0
  11. package/dist/html-extract.d.ts.map +1 -0
  12. package/dist/html-extract.js +122 -0
  13. package/dist/html-extract.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/sidecar/dispatch.d.ts +18 -0
  19. package/dist/sidecar/dispatch.d.ts.map +1 -0
  20. package/dist/sidecar/dispatch.js +294 -0
  21. package/dist/sidecar/dispatch.js.map +1 -0
  22. package/dist/sidecar/install.d.ts +37 -0
  23. package/dist/sidecar/install.d.ts.map +1 -0
  24. package/dist/sidecar/install.js +242 -0
  25. package/dist/sidecar/install.js.map +1 -0
  26. package/dist/sidecar/types.d.ts +97 -0
  27. package/dist/sidecar/types.d.ts.map +1 -0
  28. package/dist/sidecar/types.js +20 -0
  29. package/dist/sidecar/types.js.map +1 -0
  30. package/dist/sidecar.d.ts +31 -0
  31. package/dist/sidecar.d.ts.map +1 -0
  32. package/dist/sidecar.js +165 -0
  33. package/dist/sidecar.js.map +1 -0
  34. package/dist/ssrf-guard.d.ts +43 -0
  35. package/dist/ssrf-guard.d.ts.map +1 -0
  36. package/dist/ssrf-guard.js +164 -0
  37. package/dist/ssrf-guard.js.map +1 -0
  38. package/dist/web-fetch.d.ts +23 -0
  39. package/dist/web-fetch.d.ts.map +1 -0
  40. package/dist/web-fetch.js +253 -0
  41. package/dist/web-fetch.js.map +1 -0
  42. package/package.json +74 -0
  43. package/src/browser-session.test.ts +333 -0
  44. package/src/browser-session.ts +567 -0
  45. package/src/browser-surface.test.ts +367 -0
  46. package/src/browser-surface.ts +275 -0
  47. package/src/html-extract.ts +152 -0
  48. package/src/index.ts +35 -0
  49. package/src/sidecar/dispatch.test.ts +313 -0
  50. package/src/sidecar/dispatch.ts +314 -0
  51. package/src/sidecar/install.ts +283 -0
  52. package/src/sidecar/types.test.ts +26 -0
  53. package/src/sidecar/types.ts +114 -0
  54. package/src/sidecar.test.ts +57 -0
  55. package/src/sidecar.ts +167 -0
  56. package/src/ssrf-guard.test.ts +109 -0
  57. package/src/ssrf-guard.ts +165 -0
  58. package/src/web-fetch.test.ts +305 -0
  59. package/src/web-fetch.ts +311 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Minimal HTML → text / markdown extractors used by `web_fetch`. Not a
3
+ * full DOM parser: regex-based, intentionally limited. For stricter
4
+ * extraction use the markdown converter with a selector or upgrade to
5
+ * browser_session.
6
+ */
7
+
8
+ export interface ExtractOptions {
9
+ selector?: string;
10
+ }
11
+
12
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
13
+ const SCRIPT_RE = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
14
+ const STYLE_RE = /<style\b[^>]*>[\s\S]*?<\/style>/gi;
15
+
16
+ /**
17
+ * Hard ceiling on the HTML fed to the regex extractors. web_fetch caps the raw
18
+ * body at up to 20MB (maxBytes), but the lazy `[\s\S]*?` passes here run
19
+ * SYNCHRONOUSLY on the runner event loop — a multi-MB adversarial page with
20
+ * many unmatched open tags could block it. Truncating to a generous 4MB bounds
21
+ * that worst case while still covering virtually every real article.
22
+ */
23
+ const MAX_EXTRACT_INPUT = 4 * 1024 * 1024;
24
+
25
+ /** Bound the regex-extractor input so a hostile, oversized page can't stall the
26
+ * event loop. Cuts at a tag boundary when one is near the limit to avoid
27
+ * splitting an entity/tag mid-token. */
28
+ function capInput(html: string): string {
29
+ if (html.length <= MAX_EXTRACT_INPUT) return html;
30
+ const slice = html.slice(0, MAX_EXTRACT_INPUT);
31
+ const lastLt = slice.lastIndexOf('<');
32
+ return lastLt > MAX_EXTRACT_INPUT - 1024 ? slice.slice(0, lastLt) : slice;
33
+ }
34
+
35
+ /**
36
+ * Minimal HTML → plain text. Strips <script>, <style>, comments, and tags.
37
+ * Collapses whitespace. Decodes the common HTML entities.
38
+ */
39
+ export function htmlToPlainText(html: string, opts: ExtractOptions = {}): string {
40
+ let body = sliceBySelector(capInput(html), opts.selector);
41
+ body = body.replace(COMMENT_RE, '');
42
+ body = body.replace(SCRIPT_RE, '');
43
+ body = body.replace(STYLE_RE, '');
44
+ body = body.replace(/<br\b[^>]*>/gi, '\n');
45
+ body = body.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n');
46
+ body = body.replace(/<[^>]+>/g, '');
47
+ body = decodeEntities(body);
48
+ return collapseWhitespace(body);
49
+ }
50
+
51
+ /**
52
+ * Minimal HTML → markdown. Maps headings, lists, links, code blocks, and
53
+ * paragraphs. Falls through to plain-text rules for unknown structure.
54
+ */
55
+ export function htmlToMarkdown(html: string, opts: ExtractOptions = {}): string {
56
+ let body = sliceBySelector(capInput(html), opts.selector);
57
+ body = body.replace(COMMENT_RE, '');
58
+ body = body.replace(SCRIPT_RE, '');
59
+ body = body.replace(STYLE_RE, '');
60
+
61
+ // headings
62
+ body = body.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_, lvl: string, inner: string) =>
63
+ `\n\n${'#'.repeat(Number(lvl))} ${stripTags(inner).trim()}\n\n`,
64
+ );
65
+
66
+ // links: keep text + url. Accept double-quoted, single-quoted, and unquoted
67
+ // hrefs — real-world HTML uses all three, and matching only "double" silently
68
+ // dropped the URL (the link text survived via the later tag-strip).
69
+ body = body.replace(
70
+ /<a\b[^>]*\bhref=(?:"([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi,
71
+ (_, dq: string | undefined, sq: string | undefined, uq: string | undefined, inner: string) =>
72
+ `[${stripTags(inner).trim()}](${dq ?? sq ?? uq})`,
73
+ );
74
+
75
+ // code
76
+ body = body.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_, inner: string) =>
77
+ `\n\n\`\`\`\n${stripTags(inner)}\n\`\`\`\n\n`,
78
+ );
79
+ body = body.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_, inner: string) =>
80
+ `\`${stripTags(inner)}\``,
81
+ );
82
+
83
+ // lists
84
+ body = body.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_, inner: string) =>
85
+ `\n- ${stripTags(inner).trim()}`,
86
+ );
87
+ body = body.replace(/<\/?(ul|ol)\b[^>]*>/gi, '\n');
88
+
89
+ // line breaks
90
+ body = body.replace(/<br\b[^>]*>/gi, '\n');
91
+ body = body.replace(/<\/?p\b[^>]*>/gi, '\n\n');
92
+ body = body.replace(/<[^>]+>/g, '');
93
+ body = decodeEntities(body);
94
+ return collapseWhitespace(body);
95
+ }
96
+
97
+ function sliceBySelector(html: string, selector?: string): string {
98
+ if (!selector) return html;
99
+ const slice = extractFirstTagBlock(html, selector);
100
+ return slice ?? html;
101
+ }
102
+
103
+ function collapseWhitespace(s: string): string {
104
+ return s.replace(/[ \t]+/g, ' ').replace(/\n[ \t]+/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
105
+ }
106
+
107
+ function stripTags(s: string): string {
108
+ return s.replace(/<[^>]+>/g, '');
109
+ }
110
+
111
+ function decodeEntities(s: string): string {
112
+ const map: Record<string, string> = {
113
+ '&amp;': '&',
114
+ '&lt;': '<',
115
+ '&gt;': '>',
116
+ '&quot;': '"',
117
+ '&#39;': "'",
118
+ '&apos;': "'",
119
+ '&nbsp;': ' ',
120
+ };
121
+ return s.replace(/&[a-zA-Z]+;|&#\d+;/g, (m) => {
122
+ if (m in map) return map[m as keyof typeof map]!;
123
+ const numMatch = /^&#(\d+);$/.exec(m);
124
+ if (numMatch) return String.fromCharCode(Number(numMatch[1]));
125
+ return m;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Pull the first <tag>...</tag> block (or self-closing tag with id="x") whose
131
+ * tag name OR id matches `selector`. Very limited — supports `tagName` and
132
+ * `#id` only. For richer querying upgrade to browser_session.
133
+ */
134
+ function extractFirstTagBlock(html: string, selector: string): string | null {
135
+ if (selector.startsWith('#')) {
136
+ const id = selector.slice(1);
137
+ const re = new RegExp(
138
+ `<([a-z][a-z0-9-]*)\\b[^>]*\\bid=["']${escapeReSelector(id)}["'][^>]*>([\\s\\S]*?)<\\/\\1>`,
139
+ 'i',
140
+ );
141
+ const match = re.exec(html);
142
+ return match ? match[2]! : null;
143
+ }
144
+ const tag = selector.toLowerCase();
145
+ const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
146
+ const match = re.exec(html);
147
+ return match ? match[1]! : null;
148
+ }
149
+
150
+ function escapeReSelector(s: string): string {
151
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { definePlugin } from '@moxxy/sdk';
2
+ import { webFetchTool } from './web-fetch.js';
3
+ import { buildBrowserSessionTool, closeBrowserSidecar, type BrowserSessionDeps } from './browser-session.js';
4
+ import { buildBrowserSurface } from './browser-surface.js';
5
+
6
+ export { webFetchTool, htmlToPlainText, htmlToMarkdown } from './web-fetch.js';
7
+ export {
8
+ buildBrowserSessionTool,
9
+ browserSidecarCall,
10
+ closeBrowserSidecar,
11
+ type BrowserSessionDeps,
12
+ type SidecarStream,
13
+ } from './browser-session.js';
14
+ export { buildBrowserSurface } from './browser-surface.js';
15
+
16
+ export interface BuildBrowserPluginOptions extends BrowserSessionDeps {}
17
+
18
+ export function buildBrowserPlugin(opts: BuildBrowserPluginOptions = {}) {
19
+ return definePlugin({
20
+ name: '@moxxy/plugin-browser',
21
+ version: '0.0.0',
22
+ tools: [webFetchTool, buildBrowserSessionTool(opts)],
23
+ surfaces: [buildBrowserSurface(opts)],
24
+ hooks: {
25
+ onShutdown: async () => {
26
+ // Make sure the sidecar process exits with the session.
27
+ await closeBrowserSidecar();
28
+ },
29
+ },
30
+ });
31
+ }
32
+
33
+ export const browserPlugin = buildBrowserPlugin();
34
+
35
+ export default browserPlugin;
@@ -0,0 +1,313 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { dispatch, type SidecarState } from './dispatch.js';
3
+ import type { Err, Ok, PlaywrightHandle } from './types.js';
4
+ import { setSsrfDnsResolver } from '../ssrf-guard.js';
5
+
6
+ /**
7
+ * Dispatch-level SSRF tests: the sidecar is a separate process that must not
8
+ * trust the parent to have validated `goto` URLs. The guard must fire BEFORE
9
+ * Playwright is touched — these tests run with `state.handle` unset for
10
+ * blocked URLs, so reaching `ensurePlaywright` (and its `import('playwright')`)
11
+ * would fail loudly.
12
+ */
13
+
14
+ interface HandleCalls {
15
+ gotos: string[];
16
+ clicks: Array<{ x: number; y: number }>;
17
+ wheels: Array<{ dx: number; dy: number }>;
18
+ presses: string[];
19
+ types: string[];
20
+ evals: string[];
21
+ }
22
+
23
+ function makeFakeHandle(opts?: {
24
+ evalResult?: unknown;
25
+ textContent?: string | null;
26
+ content?: string;
27
+ viewport?: { width: number; height: number } | null;
28
+ }): { handle: PlaywrightHandle; gotos: string[]; calls: HandleCalls } {
29
+ const calls: HandleCalls = { gotos: [], clicks: [], wheels: [], presses: [], types: [], evals: [] };
30
+ let current = 'about:blank';
31
+ const handle: PlaywrightHandle = {
32
+ browser: { close: async () => {} },
33
+ context: { newPage: async () => ({}), close: async () => {} },
34
+ page: {
35
+ goto: async (url: string) => {
36
+ calls.gotos.push(url);
37
+ current = url;
38
+ return undefined;
39
+ },
40
+ click: async () => {},
41
+ fill: async () => {},
42
+ textContent: async () => (opts && 'textContent' in opts ? (opts.textContent ?? null) : null),
43
+ content: async () => opts?.content ?? '',
44
+ screenshot: async () => Buffer.from('screenshot-bytes'),
45
+ evaluate: async (expr: string) => {
46
+ calls.evals.push(expr);
47
+ return opts && 'evalResult' in opts ? opts.evalResult : undefined;
48
+ },
49
+ url: () => current,
50
+ close: async () => {},
51
+ viewportSize: () => (opts && 'viewport' in opts ? (opts.viewport ?? null) : { width: 800, height: 600 }),
52
+ mouse: {
53
+ click: async (x: number, y: number) => {
54
+ calls.clicks.push({ x, y });
55
+ },
56
+ wheel: async (dx: number, dy: number) => {
57
+ calls.wheels.push({ dx, dy });
58
+ },
59
+ },
60
+ keyboard: {
61
+ press: async (key: string) => {
62
+ calls.presses.push(key);
63
+ },
64
+ type: async (text: string) => {
65
+ calls.types.push(text);
66
+ },
67
+ },
68
+ },
69
+ };
70
+ return { handle, gotos: calls.gotos, calls };
71
+ }
72
+
73
+ function req(method: string, params: Record<string, unknown> = {}): { id: string; method: string; params: Record<string, unknown> } {
74
+ return { id: 'r1', method, params };
75
+ }
76
+
77
+ function gotoReq(url: string): { id: string; method: string; params: { url: string } } {
78
+ return { id: 'r1', method: 'goto', params: { url } };
79
+ }
80
+
81
+ describe('sidecar dispatch goto SSRF guard', () => {
82
+ beforeEach(() => {
83
+ // Hermetic DNS: any hostname "resolves" public unless a test overrides.
84
+ setSsrfDnsResolver(async () => ['93.184.216.34']);
85
+ });
86
+ afterEach(() => {
87
+ setSsrfDnsResolver(null);
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ it.each([
92
+ 'http://169.254.169.254/latest/meta-data/',
93
+ 'http://localhost:8080/',
94
+ 'http://10.0.0.5/internal',
95
+ 'file:///etc/passwd',
96
+ ])('rejects %s without launching a browser', async (url) => {
97
+ const state: SidecarState = { handle: null, pendingInstallNotice: null };
98
+ const reply = (await dispatch(state, gotoReq(url))) as Err;
99
+ expect(reply.ok).toBe(false);
100
+ expect(reply.error.kind).toBe('navigation');
101
+ expect(reply.error.message).toMatch(/loopback|private|scheme/);
102
+ expect(state.handle).toBeNull(); // never reached ensurePlaywright
103
+ });
104
+
105
+ it('rejects a hostname that resolves to a private address', async () => {
106
+ setSsrfDnsResolver(async () => ['192.168.0.10']);
107
+ const state: SidecarState = { handle: null, pendingInstallNotice: null };
108
+ const reply = (await dispatch(state, gotoReq('https://intranet.example.com/'))) as Err;
109
+ expect(reply.ok).toBe(false);
110
+ expect(reply.error.message).toMatch(/private|loopback/);
111
+ });
112
+
113
+ it('navigates a public URL on the (pre-seeded) page', async () => {
114
+ const { handle, gotos } = makeFakeHandle();
115
+ const state: SidecarState = { handle, pendingInstallNotice: null };
116
+ const reply = (await dispatch(state, gotoReq('https://example.com/'))) as Ok;
117
+ expect(reply.ok).toBe(true);
118
+ expect(reply.result).toEqual({ url: 'https://example.com/' });
119
+ expect(gotos).toEqual(['https://example.com/']);
120
+ });
121
+ });
122
+
123
+ describe('sidecar dispatch removed screencast methods', () => {
124
+ // The CDP screencast push path was orphaned by the PR #205 polling revert and
125
+ // deleted. Its former methods must now fall through to the default branch and
126
+ // report `unknown method` (not silently succeed). Uses a pre-seeded handle so
127
+ // we never reach `ensurePlaywright` / a real Playwright import.
128
+ it.each(['startScreencast', 'stopScreencast'])(
129
+ 'reports %s as an unknown method',
130
+ async (method) => {
131
+ const { handle } = makeFakeHandle();
132
+ const state: SidecarState = { handle, pendingInstallNotice: null };
133
+ const reply = (await dispatch(state, { id: 'r1', method, params: {} })) as Err;
134
+ expect(reply.ok).toBe(false);
135
+ expect(reply.error.kind).toBe('runtime');
136
+ expect(reply.error.message).toBe(`unknown method: ${method}`);
137
+ },
138
+ );
139
+ });
140
+
141
+ describe('sidecar dispatch protocol methods (against a pre-seeded handle)', () => {
142
+ // Each test pre-seeds state.handle so `ensurePlaywright` short-circuits and no
143
+ // real Playwright import / launch happens. These assert the exact wire shapes
144
+ // the surface + tool callers consume.
145
+
146
+ it('text without a selector reads whole-document innerText via evaluate', async () => {
147
+ const { handle, calls } = makeFakeHandle({ evalResult: 'page body text' });
148
+ const state: SidecarState = { handle, pendingInstallNotice: null };
149
+ const reply = (await dispatch(state, req('text'))) as Ok;
150
+ expect(reply.ok).toBe(true);
151
+ expect(reply.result).toBe('page body text');
152
+ expect(calls.evals).toHaveLength(1);
153
+ });
154
+
155
+ it('text with a selector returns its textContent (empty string when null)', async () => {
156
+ const { handle } = makeFakeHandle({ textContent: null });
157
+ const state: SidecarState = { handle, pendingInstallNotice: null };
158
+ const reply = (await dispatch(state, req('text', { selector: 'main' }))) as Ok;
159
+ expect(reply.ok).toBe(true);
160
+ expect(reply.result).toBe('');
161
+ });
162
+
163
+ it('html returns the page content', async () => {
164
+ const { handle } = makeFakeHandle({ content: '<html>x</html>' });
165
+ const state: SidecarState = { handle, pendingInstallNotice: null };
166
+ const reply = (await dispatch(state, req('html'))) as Ok;
167
+ expect(reply.result).toBe('<html>x</html>');
168
+ });
169
+
170
+ it('screenshot returns a png mediaType + base64 payload', async () => {
171
+ const { handle } = makeFakeHandle();
172
+ const state: SidecarState = { handle, pendingInstallNotice: null };
173
+ const reply = (await dispatch(state, req('screenshot'))) as Ok;
174
+ expect(reply.result).toEqual({
175
+ mediaType: 'image/png',
176
+ base64: Buffer.from('screenshot-bytes').toString('base64'),
177
+ });
178
+ });
179
+
180
+ it('frame returns jpeg + url + viewport dimensions', async () => {
181
+ const { handle } = makeFakeHandle({ viewport: { width: 1024, height: 768 } });
182
+ const state: SidecarState = { handle, pendingInstallNotice: null };
183
+ const reply = (await dispatch(state, req('frame'))) as Ok;
184
+ expect(reply.result).toEqual({
185
+ mediaType: 'image/jpeg',
186
+ base64: Buffer.from('screenshot-bytes').toString('base64'),
187
+ url: 'about:blank',
188
+ width: 1024,
189
+ height: 768,
190
+ });
191
+ });
192
+
193
+ it('frame falls back to 1280x720 when viewportSize() is null', async () => {
194
+ const { handle } = makeFakeHandle({ viewport: null });
195
+ const state: SidecarState = { handle, pendingInstallNotice: null };
196
+ const reply = (await dispatch(state, req('frame'))) as Ok;
197
+ expect(reply.result).toMatchObject({ width: 1280, height: 720 });
198
+ });
199
+
200
+ it('mouse forwards finite coords to page.mouse.click and returns the url', async () => {
201
+ const { handle, calls } = makeFakeHandle();
202
+ const state: SidecarState = { handle, pendingInstallNotice: null };
203
+ const reply = (await dispatch(state, req('mouse', { x: 12, y: 34 }))) as Ok;
204
+ expect(reply.ok).toBe(true);
205
+ expect(reply.result).toEqual({ url: 'about:blank' });
206
+ expect(calls.clicks).toEqual([{ x: 12, y: 34 }]);
207
+ });
208
+
209
+ it.each([
210
+ ['missing both', {}],
211
+ ['missing y', { x: 5 }],
212
+ ['NaN x', { x: Number.NaN, y: 2 }],
213
+ ])('mouse with %s coords returns a runtime badParams without touching the page', async (_label, params) => {
214
+ const { handle, calls } = makeFakeHandle();
215
+ const state: SidecarState = { handle, pendingInstallNotice: null };
216
+ const reply = (await dispatch(state, req('mouse', params))) as Err;
217
+ expect(reply.ok).toBe(false);
218
+ expect(reply.error.kind).toBe('runtime');
219
+ expect(calls.clicks).toHaveLength(0);
220
+ });
221
+
222
+ it('key types a single printable char and presses a named key', async () => {
223
+ const { handle, calls } = makeFakeHandle();
224
+ const state: SidecarState = { handle, pendingInstallNotice: null };
225
+ expect(((await dispatch(state, req('key', { key: 'a' }))) as Ok).ok).toBe(true);
226
+ expect(((await dispatch(state, req('key', { key: 'Enter' }))) as Ok).ok).toBe(true);
227
+ expect(calls.types).toEqual(['a']);
228
+ expect(calls.presses).toEqual(['Enter']);
229
+ });
230
+
231
+ it('key without a key value returns a runtime badParams', async () => {
232
+ const { handle } = makeFakeHandle();
233
+ const state: SidecarState = { handle, pendingInstallNotice: null };
234
+ const reply = (await dispatch(state, req('key', {}))) as Err;
235
+ expect(reply.ok).toBe(false);
236
+ expect(reply.error.kind).toBe('runtime');
237
+ });
238
+
239
+ it('scroll forwards dy to mouse.wheel and defaults missing dy to 0', async () => {
240
+ const { handle, calls } = makeFakeHandle();
241
+ const state: SidecarState = { handle, pendingInstallNotice: null };
242
+ await dispatch(state, req('scroll', { dy: 120 }));
243
+ await dispatch(state, req('scroll', {}));
244
+ expect(calls.wheels).toEqual([
245
+ { dx: 0, dy: 120 },
246
+ { dx: 0, dy: 0 },
247
+ ]);
248
+ });
249
+
250
+ it('eval forwards the expression and returns its value', async () => {
251
+ const { handle, calls } = makeFakeHandle({ evalResult: 42 });
252
+ const state: SidecarState = { handle, pendingInstallNotice: null };
253
+ const reply = (await dispatch(state, req('eval', { expression: '1 + 41' }))) as Ok;
254
+ expect(reply.result).toBe(42);
255
+ expect(calls.evals).toEqual(['1 + 41']);
256
+ });
257
+
258
+ it('eval without an expression returns a runtime badParams', async () => {
259
+ const { handle } = makeFakeHandle();
260
+ const state: SidecarState = { handle, pendingInstallNotice: null };
261
+ const reply = (await dispatch(state, req('eval', {}))) as Err;
262
+ expect(reply.ok).toBe(false);
263
+ expect(reply.error.kind).toBe('runtime');
264
+ });
265
+
266
+ it('url returns the current page url', async () => {
267
+ const { handle } = makeFakeHandle();
268
+ const state: SidecarState = { handle, pendingInstallNotice: null };
269
+ const reply = (await dispatch(state, req('url'))) as Ok;
270
+ expect(reply.result).toBe('about:blank');
271
+ });
272
+
273
+ it('close tears down the handle and is idempotent', async () => {
274
+ const { handle } = makeFakeHandle();
275
+ const state: SidecarState = { handle, pendingInstallNotice: null };
276
+ expect(((await dispatch(state, req('close'))) as Ok).ok).toBe(true);
277
+ expect(state.handle).toBeNull();
278
+ // A second close (handle already null) still replies ok.
279
+ expect(((await dispatch(state, req('close'))) as Ok).ok).toBe(true);
280
+ });
281
+
282
+ it.each([
283
+ ['setviewport', { width: 1e9, height: 100 }],
284
+ ['setviewport', { width: 100, height: 1e9 }],
285
+ ['capture', { x: 0, y: 0, width: 100_000, height: 10 }],
286
+ ['capture', { x: 0, y: 0, width: 10, height: 100_000 }],
287
+ ])('%s with an over-limit dimension returns badParams without launching a browser', async (method, params) => {
288
+ // state.handle stays null so reaching ensurePlaywright (a real Playwright
289
+ // import) would fail loudly — the dimension clamp must fire first.
290
+ const state: SidecarState = { handle: null, pendingInstallNotice: null };
291
+ const reply = (await dispatch(state, req(method, params))) as Err;
292
+ expect(reply.ok).toBe(false);
293
+ expect(reply.error.kind).toBe('runtime');
294
+ expect(reply.error.message).toMatch(/<=|must be/);
295
+ expect(state.handle).toBeNull();
296
+ });
297
+
298
+ it('click requires a selector', async () => {
299
+ const { handle } = makeFakeHandle();
300
+ const state: SidecarState = { handle, pendingInstallNotice: null };
301
+ const reply = (await dispatch(state, req('click', {}))) as Err;
302
+ expect(reply.ok).toBe(false);
303
+ expect(reply.error.kind).toBe('runtime');
304
+ });
305
+
306
+ it('fill requires a selector', async () => {
307
+ const { handle } = makeFakeHandle();
308
+ const state: SidecarState = { handle, pendingInstallNotice: null };
309
+ const reply = (await dispatch(state, req('fill', { value: 'x' }))) as Err;
310
+ expect(reply.ok).toBe(false);
311
+ expect(reply.error.kind).toBe('runtime');
312
+ });
313
+ });