@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.
- package/LICENSE +21 -0
- package/dist/browser-session.d.ts +43 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +500 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/browser-surface.d.ts +3 -0
- package/dist/browser-surface.d.ts.map +1 -0
- package/dist/browser-surface.js +255 -0
- package/dist/browser-surface.js.map +1 -0
- package/dist/html-extract.d.ts +20 -0
- package/dist/html-extract.d.ts.map +1 -0
- package/dist/html-extract.js +122 -0
- package/dist/html-extract.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sidecar/dispatch.d.ts +18 -0
- package/dist/sidecar/dispatch.d.ts.map +1 -0
- package/dist/sidecar/dispatch.js +294 -0
- package/dist/sidecar/dispatch.js.map +1 -0
- package/dist/sidecar/install.d.ts +37 -0
- package/dist/sidecar/install.d.ts.map +1 -0
- package/dist/sidecar/install.js +242 -0
- package/dist/sidecar/install.js.map +1 -0
- package/dist/sidecar/types.d.ts +97 -0
- package/dist/sidecar/types.d.ts.map +1 -0
- package/dist/sidecar/types.js +20 -0
- package/dist/sidecar/types.js.map +1 -0
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +165 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/ssrf-guard.d.ts +43 -0
- package/dist/ssrf-guard.d.ts.map +1 -0
- package/dist/ssrf-guard.js +164 -0
- package/dist/ssrf-guard.js.map +1 -0
- package/dist/web-fetch.d.ts +23 -0
- package/dist/web-fetch.d.ts.map +1 -0
- package/dist/web-fetch.js +253 -0
- package/dist/web-fetch.js.map +1 -0
- package/package.json +74 -0
- package/src/browser-session.test.ts +333 -0
- package/src/browser-session.ts +567 -0
- package/src/browser-surface.test.ts +367 -0
- package/src/browser-surface.ts +275 -0
- package/src/html-extract.ts +152 -0
- package/src/index.ts +35 -0
- package/src/sidecar/dispatch.test.ts +313 -0
- package/src/sidecar/dispatch.ts +314 -0
- package/src/sidecar/install.ts +283 -0
- package/src/sidecar/types.test.ts +26 -0
- package/src/sidecar/types.ts +114 -0
- package/src/sidecar.test.ts +57 -0
- package/src/sidecar.ts +167 -0
- package/src/ssrf-guard.test.ts +109 -0
- package/src/ssrf-guard.ts +165 -0
- package/src/web-fetch.test.ts +305 -0
- 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
|
+
'&': '&',
|
|
114
|
+
'<': '<',
|
|
115
|
+
'>': '>',
|
|
116
|
+
'"': '"',
|
|
117
|
+
''': "'",
|
|
118
|
+
''': "'",
|
|
119
|
+
' ': ' ',
|
|
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
|
+
});
|