@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,367 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { buildBrowserSurface } from './browser-surface.js';
|
|
4
|
+
import { closeBrowserSidecar, type SidecarStream } from './browser-session.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The browser surface polls the shared sidecar's `frame` method a few times a
|
|
8
|
+
* second and forwards each JPEG as a `surface.data` payload. These tests drive
|
|
9
|
+
* a FAKE sidecar (injected via deps.spawnFn) whose per-method behaviour the
|
|
10
|
+
* test controls, plus fake timers to step the poll interval — so the
|
|
11
|
+
* inFlight guard, the FAIL_GRACE status logic, snapshot catch-up, and the
|
|
12
|
+
* input coordinate de-normalization are exercised without Playwright.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FRAME_INTERVAL_MS = 300;
|
|
16
|
+
const FAIL_GRACE = 6;
|
|
17
|
+
|
|
18
|
+
type Reply = { ok: true; result: unknown } | { ok: false; message: string; kind?: string };
|
|
19
|
+
|
|
20
|
+
interface FakeSidecar {
|
|
21
|
+
spawn: (path: string) => SidecarStream;
|
|
22
|
+
/** Set the handler invoked for each incoming method. */
|
|
23
|
+
setHandler: (fn: (method: string, params: unknown) => Reply) => void;
|
|
24
|
+
received: Array<{ method: string; params: unknown }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeControllableSpawn(): FakeSidecar {
|
|
28
|
+
const received: Array<{ method: string; params: unknown }> = [];
|
|
29
|
+
let handler: (method: string, params: unknown) => Reply = () => ({ ok: false, message: 'no handler' });
|
|
30
|
+
const spawn = (_path: string): SidecarStream => {
|
|
31
|
+
const stdin = new PassThrough();
|
|
32
|
+
const stdout = new PassThrough();
|
|
33
|
+
let buf = '';
|
|
34
|
+
stdin.on('data', (chunk) => {
|
|
35
|
+
buf += chunk.toString('utf8');
|
|
36
|
+
let nl: number;
|
|
37
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
38
|
+
const line = buf.slice(0, nl);
|
|
39
|
+
buf = buf.slice(nl + 1);
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
const reqMsg = JSON.parse(line) as { id: string; method: string; params: unknown };
|
|
42
|
+
received.push({ method: reqMsg.method, params: reqMsg.params });
|
|
43
|
+
const r = handler(reqMsg.method, reqMsg.params);
|
|
44
|
+
const reply = r.ok
|
|
45
|
+
? { id: reqMsg.id, ok: true, result: r.result }
|
|
46
|
+
: { id: reqMsg.id, ok: false, error: { message: r.message, kind: r.kind } };
|
|
47
|
+
stdout.write(JSON.stringify(reply) + '\n');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const exitListeners: Array<(code: number | null) => void> = [];
|
|
51
|
+
return {
|
|
52
|
+
stdin,
|
|
53
|
+
stdout,
|
|
54
|
+
kill: () => {
|
|
55
|
+
for (const l of exitListeners) l(0);
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
once: (_event, listener) => {
|
|
59
|
+
exitListeners.push(listener as (code: number | null) => void);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
return { spawn, setHandler: (fn) => (handler = fn), received };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const frame = (over: Partial<{ base64: string; mediaType: string; url: string; width: number; height: number }> = {}) => ({
|
|
67
|
+
base64: 'AAAA',
|
|
68
|
+
mediaType: 'image/jpeg',
|
|
69
|
+
url: 'https://example.com/',
|
|
70
|
+
width: 1000,
|
|
71
|
+
height: 500,
|
|
72
|
+
...over,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Flush microtasks so the async tick() round-trips through the fake streams. */
|
|
76
|
+
const flush = async (): Promise<void> => {
|
|
77
|
+
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
vi.useRealTimers();
|
|
82
|
+
await closeBrowserSidecar();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('browser surface polling lifecycle', () => {
|
|
86
|
+
it('emits a frame payload and tracks it as the snapshot', async () => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
const sidecar = makeControllableSpawn();
|
|
89
|
+
sidecar.setHandler((method) => (method === 'frame' ? { ok: true, result: frame() } : { ok: false, message: 'x' }));
|
|
90
|
+
|
|
91
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
92
|
+
const payloads: unknown[] = [];
|
|
93
|
+
surface.onData((p) => payloads.push(p));
|
|
94
|
+
|
|
95
|
+
await flush(); // the immediate kick tick()
|
|
96
|
+
expect(payloads).toEqual([{ type: 'frame', base64: 'AAAA', mime: 'image/jpeg', url: 'https://example.com/' }]);
|
|
97
|
+
expect(surface.snapshot()).toEqual({ type: 'frame', base64: 'AAAA', mime: 'image/jpeg', url: 'https://example.com/' });
|
|
98
|
+
|
|
99
|
+
surface.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('snapshot reports "Starting browser…" before any frame arrives', () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
const sidecar = makeControllableSpawn();
|
|
105
|
+
sidecar.setHandler(() => ({ ok: false, message: 'not yet' }));
|
|
106
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
107
|
+
expect(surface.snapshot()).toEqual({ type: 'status', text: 'Starting browser…' });
|
|
108
|
+
surface.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('does NOT emit a status on the first failures, but DOES on the FAIL_GRACE-th (no prior frame)', async () => {
|
|
112
|
+
vi.useFakeTimers();
|
|
113
|
+
const sidecar = makeControllableSpawn();
|
|
114
|
+
sidecar.setHandler(() => ({ ok: false, message: 'launch failed' }));
|
|
115
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
116
|
+
const statuses: Array<{ type: string; text?: string }> = [];
|
|
117
|
+
surface.onData((p) => statuses.push(p as { type: string; text?: string }));
|
|
118
|
+
|
|
119
|
+
await flush(); // tick #1 (the kick)
|
|
120
|
+
expect(statuses).toHaveLength(0);
|
|
121
|
+
// Advance to the FAIL_GRACE-th failure. Each interval triggers one tick.
|
|
122
|
+
for (let i = 1; i < FAIL_GRACE; i++) {
|
|
123
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
124
|
+
await flush();
|
|
125
|
+
}
|
|
126
|
+
expect(statuses).toEqual([{ type: 'status', text: 'Browser unavailable: launch failed' }]);
|
|
127
|
+
|
|
128
|
+
// Further failures do NOT re-emit (status fires once per failure streak).
|
|
129
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
130
|
+
await flush();
|
|
131
|
+
expect(statuses).toHaveLength(1);
|
|
132
|
+
|
|
133
|
+
surface.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a successful frame resets the failure counter', async () => {
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
const sidecar = makeControllableSpawn();
|
|
139
|
+
let ok = false;
|
|
140
|
+
sidecar.setHandler(() => (ok ? { ok: true, result: frame() } : { ok: false, message: 'flaky' }));
|
|
141
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
142
|
+
const events: Array<{ type: string }> = [];
|
|
143
|
+
surface.onData((p) => events.push(p as { type: string }));
|
|
144
|
+
|
|
145
|
+
await flush(); // fail #1
|
|
146
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS); // fail #2
|
|
147
|
+
await flush();
|
|
148
|
+
ok = true; // a good frame lands, resetting fails
|
|
149
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
150
|
+
await flush();
|
|
151
|
+
ok = false; // start failing again
|
|
152
|
+
|
|
153
|
+
// Because the good frame reset `fails` to 0, we must accrue FAIL_GRACE
|
|
154
|
+
// fresh failures via FAIL_GRACE more ticks before the status fires.
|
|
155
|
+
for (let i = 0; i < FAIL_GRACE; i++) {
|
|
156
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
157
|
+
await flush();
|
|
158
|
+
}
|
|
159
|
+
// The single status here is the "disconnected" one (a prior frame existed).
|
|
160
|
+
const statuses = events.filter((e) => e.type === 'status');
|
|
161
|
+
expect(statuses).toHaveLength(1);
|
|
162
|
+
expect((statuses[0] as { text: string }).text).toMatch(/disconnected/);
|
|
163
|
+
|
|
164
|
+
surface.close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('surfaces a "disconnected" status when the page dies after a frame was shown', async () => {
|
|
168
|
+
vi.useFakeTimers();
|
|
169
|
+
const sidecar = makeControllableSpawn();
|
|
170
|
+
let alive = true;
|
|
171
|
+
sidecar.setHandler(() => (alive ? { ok: true, result: frame() } : { ok: false, message: 'page crashed' }));
|
|
172
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
173
|
+
const events: Array<{ type: string; text?: string }> = [];
|
|
174
|
+
surface.onData((p) => events.push(p as { type: string; text?: string }));
|
|
175
|
+
|
|
176
|
+
await flush(); // good frame
|
|
177
|
+
alive = false;
|
|
178
|
+
for (let i = 0; i < FAIL_GRACE; i++) {
|
|
179
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
180
|
+
await flush();
|
|
181
|
+
}
|
|
182
|
+
const statuses = events.filter((e) => e.type === 'status');
|
|
183
|
+
expect(statuses).toEqual([{ type: 'status', text: 'Browser disconnected: page crashed' }]);
|
|
184
|
+
// The stale frame is still available via snapshot.
|
|
185
|
+
expect(surface.snapshot()).toMatchObject({ type: 'frame' });
|
|
186
|
+
|
|
187
|
+
surface.close();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('a needs-install error pauses polling and emits an install prompt', async () => {
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
const sidecar = makeControllableSpawn();
|
|
193
|
+
sidecar.setHandler(() => ({ ok: false, message: 'Playwright is not installed.', kind: 'needs-install' }));
|
|
194
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
195
|
+
const events: Array<{ type: string; needsInstall?: boolean; text?: string }> = [];
|
|
196
|
+
surface.onData((p) => events.push(p as { type: string; needsInstall?: boolean }));
|
|
197
|
+
|
|
198
|
+
await flush(); // the kick tick() hits the needs-install reply immediately
|
|
199
|
+
expect(events).toEqual([
|
|
200
|
+
expect.objectContaining({ type: 'status', needsInstall: true }),
|
|
201
|
+
]);
|
|
202
|
+
// Snapshot for a late viewer also reports the install affordance.
|
|
203
|
+
expect(surface.snapshot()).toMatchObject({ type: 'status', needsInstall: true });
|
|
204
|
+
|
|
205
|
+
// Polling is paused: no further `frame` calls reach the sidecar (we don't
|
|
206
|
+
// spin on an import that will keep failing).
|
|
207
|
+
const before = sidecar.received.length;
|
|
208
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS * 4);
|
|
209
|
+
await flush();
|
|
210
|
+
expect(sidecar.received.length).toBe(before);
|
|
211
|
+
|
|
212
|
+
surface.close();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('treats a malformed frame reply as a failed tick (no blank frame emitted)', async () => {
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
const sidecar = makeControllableSpawn();
|
|
218
|
+
// The sidecar replies ok, but with a frame missing base64/url — a partial or
|
|
219
|
+
// altered reply. The surface must NOT stream {base64: undefined} to subs.
|
|
220
|
+
sidecar.setHandler((method) =>
|
|
221
|
+
method === 'frame' ? { ok: true, result: { mediaType: 'image/jpeg' } } : { ok: false, message: 'x' },
|
|
222
|
+
);
|
|
223
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
224
|
+
const events: Array<{ type: string }> = [];
|
|
225
|
+
surface.onData((p) => events.push(p as { type: string }));
|
|
226
|
+
|
|
227
|
+
await flush();
|
|
228
|
+
// No frame payload streamed (it was treated as a failure).
|
|
229
|
+
expect(events.filter((e) => e.type === 'frame')).toHaveLength(0);
|
|
230
|
+
// Drive past FAIL_GRACE: a status surfaces instead of silent blanks.
|
|
231
|
+
for (let i = 1; i < FAIL_GRACE; i++) {
|
|
232
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS);
|
|
233
|
+
await flush();
|
|
234
|
+
}
|
|
235
|
+
expect(events.some((e) => e.type === 'status')).toBe(true);
|
|
236
|
+
|
|
237
|
+
surface.close();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('close() stops the poll — no further frame calls reach the sidecar', async () => {
|
|
241
|
+
vi.useFakeTimers();
|
|
242
|
+
const sidecar = makeControllableSpawn();
|
|
243
|
+
sidecar.setHandler(() => ({ ok: true, result: frame() }));
|
|
244
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
245
|
+
await flush();
|
|
246
|
+
const before = sidecar.received.length;
|
|
247
|
+
surface.close();
|
|
248
|
+
await vi.advanceTimersByTimeAsync(FRAME_INTERVAL_MS * 4);
|
|
249
|
+
await flush();
|
|
250
|
+
expect(sidecar.received.length).toBe(before);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('browser surface input mapping', () => {
|
|
255
|
+
it('click maps normalized fx/fy to viewport pixel coords from the last frame', async () => {
|
|
256
|
+
vi.useFakeTimers();
|
|
257
|
+
const sidecar = makeControllableSpawn();
|
|
258
|
+
sidecar.setHandler((method) =>
|
|
259
|
+
method === 'frame' ? { ok: true, result: frame({ width: 1000, height: 500 }) } : { ok: true, result: { url: 'x' } },
|
|
260
|
+
);
|
|
261
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
262
|
+
await flush(); // establish last frame (1000x500)
|
|
263
|
+
|
|
264
|
+
await surface.input({ type: 'click', fx: 0.5, fy: 0.2 });
|
|
265
|
+
await flush();
|
|
266
|
+
const mouseCall = sidecar.received.find((r) => r.method === 'mouse');
|
|
267
|
+
expect(mouseCall?.params).toEqual({ x: 500, y: 100, count: 1 });
|
|
268
|
+
|
|
269
|
+
surface.close();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('click before any frame uses the 1280x720 fallback viewport', async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
const sidecar = makeControllableSpawn();
|
|
275
|
+
// Frame fails so `last` stays null, but mouse succeeds.
|
|
276
|
+
sidecar.setHandler((method) => (method === 'mouse' ? { ok: true, result: { url: 'x' } } : { ok: false, message: 'no frame' }));
|
|
277
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
278
|
+
await flush();
|
|
279
|
+
|
|
280
|
+
await surface.input({ type: 'click', fx: 1, fy: 1 });
|
|
281
|
+
await flush();
|
|
282
|
+
const mouseCall = sidecar.received.find((r) => r.method === 'mouse');
|
|
283
|
+
expect(mouseCall?.params).toEqual({ x: 1280, y: 720, count: 1 });
|
|
284
|
+
|
|
285
|
+
surface.close();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('resize sets the page viewport so the view fills the pane', async () => {
|
|
289
|
+
vi.useFakeTimers();
|
|
290
|
+
const sidecar = makeControllableSpawn();
|
|
291
|
+
sidecar.setHandler(() => ({ ok: true, result: frame() }));
|
|
292
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
293
|
+
await flush();
|
|
294
|
+
await surface.resize?.({ width: 900, height: 600 });
|
|
295
|
+
await flush();
|
|
296
|
+
const vp = sidecar.received.find((r) => r.method === 'setviewport');
|
|
297
|
+
expect(vp?.params).toEqual({ width: 900, height: 600 });
|
|
298
|
+
surface.close();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('dblclick forwards clickCount 2; hover + nav reach their sidecar methods', async () => {
|
|
302
|
+
vi.useFakeTimers();
|
|
303
|
+
const sidecar = makeControllableSpawn();
|
|
304
|
+
sidecar.setHandler((method) => (method === 'frame' ? { ok: true, result: frame({ width: 1000, height: 500 }) } : { ok: true, result: {} }));
|
|
305
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
306
|
+
await flush();
|
|
307
|
+
|
|
308
|
+
await surface.input({ type: 'dblclick', fx: 0.5, fy: 0.5 });
|
|
309
|
+
await surface.input({ type: 'move', fx: 0.1, fy: 0.2 });
|
|
310
|
+
await surface.input({ type: 'reload' });
|
|
311
|
+
await flush();
|
|
312
|
+
|
|
313
|
+
expect(sidecar.received.find((r) => r.method === 'mouse')?.params).toEqual({ x: 500, y: 250, count: 2 });
|
|
314
|
+
expect(sidecar.received.find((r) => r.method === 'mousemove')?.params).toEqual({ x: 100, y: 100 });
|
|
315
|
+
expect(sidecar.received.some((r) => r.method === 'reload')).toBe(true);
|
|
316
|
+
surface.close();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('zoom forwards the factor; capture maps the region and emits the PNG', async () => {
|
|
320
|
+
vi.useFakeTimers();
|
|
321
|
+
const sidecar = makeControllableSpawn();
|
|
322
|
+
sidecar.setHandler((method) => {
|
|
323
|
+
if (method === 'frame') return { ok: true, result: frame({ width: 1000, height: 500 }) };
|
|
324
|
+
if (method === 'capture') return { ok: true, result: { mediaType: 'image/png', base64: 'PNGDATA' } };
|
|
325
|
+
return { ok: true, result: {} };
|
|
326
|
+
});
|
|
327
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
328
|
+
const events: Array<{ type: string; base64?: string }> = [];
|
|
329
|
+
surface.onData((p) => events.push(p as { type: string }));
|
|
330
|
+
await flush();
|
|
331
|
+
|
|
332
|
+
await surface.input({ type: 'zoom', factor: 1.5 });
|
|
333
|
+
await surface.input({ type: 'capture', fx: 0.1, fy: 0.2, fw: 0.5, fh: 0.4 });
|
|
334
|
+
await flush();
|
|
335
|
+
|
|
336
|
+
expect(sidecar.received.find((r) => r.method === 'zoom')?.params).toEqual({ factor: 1.5 });
|
|
337
|
+
// normalized region × viewport (1000×500) → CSS px clip.
|
|
338
|
+
expect(sidecar.received.find((r) => r.method === 'capture')?.params).toEqual({
|
|
339
|
+
x: 100,
|
|
340
|
+
y: 100,
|
|
341
|
+
width: 500,
|
|
342
|
+
height: 200,
|
|
343
|
+
});
|
|
344
|
+
expect(events.find((e) => e.type === 'captured')?.base64).toBe('PNGDATA');
|
|
345
|
+
surface.close();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('navigate proxies goto and surfaces a goto rejection as a status line', async () => {
|
|
349
|
+
vi.useFakeTimers();
|
|
350
|
+
const sidecar = makeControllableSpawn();
|
|
351
|
+
sidecar.setHandler((method) => {
|
|
352
|
+
if (method === 'goto') return { ok: false, message: 'SSRF blocked' };
|
|
353
|
+
return { ok: false, message: 'no frame' };
|
|
354
|
+
});
|
|
355
|
+
const surface = buildBrowserSurface({ sidecarPath: '/fake.js', spawnFn: sidecar.spawn }).open();
|
|
356
|
+
const events: Array<{ type: string; text?: string }> = [];
|
|
357
|
+
surface.onData((p) => events.push(p as { type: string; text?: string }));
|
|
358
|
+
await flush();
|
|
359
|
+
|
|
360
|
+
await surface.input({ type: 'navigate', url: 'http://10.0.0.1/' });
|
|
361
|
+
await flush();
|
|
362
|
+
expect(events.some((e) => e.type === 'status' && e.text === 'SSRF blocked')).toBe(true);
|
|
363
|
+
expect(sidecar.received.some((r) => r.method === 'goto')).toBe(true);
|
|
364
|
+
|
|
365
|
+
surface.close();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { defineSurface, type SurfaceInstance } from '@moxxy/sdk';
|
|
2
|
+
import {
|
|
3
|
+
browserSidecarCall,
|
|
4
|
+
closeBrowserSidecar,
|
|
5
|
+
resolveBrowserInstallRoot,
|
|
6
|
+
sidecarErrorKind,
|
|
7
|
+
type BrowserSessionDeps,
|
|
8
|
+
} from './browser-session.js';
|
|
9
|
+
import { installPlaywrightPackage } from './sidecar/install.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `browser` surface: a live, in-window view of the SAME Playwright page the
|
|
13
|
+
* `browser_session` tool drives. We "stream" the page by polling a JPEG frame
|
|
14
|
+
* (`frame` sidecar method) a few times a second and forwarding it as a
|
|
15
|
+
* `surface.data` payload; the user's clicks/keys/scroll/navigate are proxied
|
|
16
|
+
* back onto the page via the sidecar's coordinate-based input methods. So the
|
|
17
|
+
* agent and the user operate ONE shared page — agent navigations show up in the
|
|
18
|
+
* pane, and the user can take over.
|
|
19
|
+
*
|
|
20
|
+
* Why polling and not a CDP `Page.startScreencast` push: the screencast only
|
|
21
|
+
* emits on visual change, so a freshly-opened (blank / static / headless) page
|
|
22
|
+
* produces no frames at all — the pane sat on "Loading…" forever with the
|
|
23
|
+
* underlying error swallowed. A screenshot poll always yields a frame (even a
|
|
24
|
+
* blank one), so the view comes up reliably and a real launch/install failure
|
|
25
|
+
* surfaces as a status line instead of an indefinite spinner. Polling rides the
|
|
26
|
+
* existing sidecar RPC surface and works on every Playwright browser, not just
|
|
27
|
+
* Chromium.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const FRAME_INTERVAL_MS = 300;
|
|
31
|
+
/** Consecutive `frame` failures (with no frame ever seen) before we stop
|
|
32
|
+
* assuming "still launching" and show the error. ~6 × 300ms ≈ 1.8s grace. */
|
|
33
|
+
const FAIL_GRACE = 6;
|
|
34
|
+
|
|
35
|
+
interface Frame {
|
|
36
|
+
mediaType: string;
|
|
37
|
+
base64: string;
|
|
38
|
+
url: string;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validate a sidecar `frame` reply before emitting it: a partial / altered
|
|
44
|
+
* reply must be treated as a failed tick (so FAIL_GRACE eventually surfaces a
|
|
45
|
+
* status) rather than streamed to subscribers as `{base64: undefined, …}`. */
|
|
46
|
+
function isFrame(v: unknown): v is Frame {
|
|
47
|
+
if (!v || typeof v !== 'object') return false;
|
|
48
|
+
const f = v as Record<string, unknown>;
|
|
49
|
+
return typeof f.base64 === 'string' && typeof f.mediaType === 'string' && typeof f.url === 'string';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildBrowserSurface(deps?: BrowserSessionDeps) {
|
|
53
|
+
return defineSurface({
|
|
54
|
+
kind: 'browser',
|
|
55
|
+
description: "A live view of the agent's browser; click, type, and navigate.",
|
|
56
|
+
open: (): SurfaceInstance => {
|
|
57
|
+
const dataSubs = new Set<(payload: unknown) => void>();
|
|
58
|
+
let last: Frame | null = null;
|
|
59
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
60
|
+
let inFlight = false;
|
|
61
|
+
let fails = 0;
|
|
62
|
+
// Set once we detect the `playwright` npm package isn't installed. Polling
|
|
63
|
+
// pauses (no point retrying an import that will keep failing) and the pane
|
|
64
|
+
// shows an Install affordance; an `install` input clears it.
|
|
65
|
+
let needsInstall = false;
|
|
66
|
+
let installing = false;
|
|
67
|
+
|
|
68
|
+
const emit = (payload: unknown): void => {
|
|
69
|
+
for (const cb of dataSubs) cb(payload);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const stopPolling = (): void => {
|
|
73
|
+
if (timer) clearInterval(timer);
|
|
74
|
+
timer = null;
|
|
75
|
+
};
|
|
76
|
+
const startPolling = (): void => {
|
|
77
|
+
if (timer) return;
|
|
78
|
+
void tick();
|
|
79
|
+
timer = setInterval(() => void tick(), FRAME_INTERVAL_MS);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const tick = async (): Promise<void> => {
|
|
83
|
+
if (inFlight || needsInstall || installing) return; // don't pile up / retry a known-missing dep
|
|
84
|
+
inFlight = true;
|
|
85
|
+
try {
|
|
86
|
+
const reply = await browserSidecarCall('frame', {}, deps);
|
|
87
|
+
if (!isFrame(reply)) throw new Error('malformed frame reply from sidecar');
|
|
88
|
+
last = reply;
|
|
89
|
+
fails = 0;
|
|
90
|
+
emit({ type: 'frame', base64: reply.base64, mime: reply.mediaType, url: reply.url });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// The `playwright` npm package is simply absent — recoverable. Pause
|
|
93
|
+
// polling and ask the user (the download is ~200MB) rather than spin on
|
|
94
|
+
// a failing import or dump a raw "not installed" error.
|
|
95
|
+
if (sidecarErrorKind(err) === 'needs-install') {
|
|
96
|
+
needsInstall = true;
|
|
97
|
+
stopPolling();
|
|
98
|
+
emit({
|
|
99
|
+
type: 'status',
|
|
100
|
+
needsInstall: true,
|
|
101
|
+
text: 'The browser engine (Playwright) is not installed. It is a one-time ~200MB download.',
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// The first failures are usually the browser still launching (or a
|
|
106
|
+
// one-time binary install). Only surface a hard error once it's
|
|
107
|
+
// clearly not transient, so the user isn't left on a silent spinner.
|
|
108
|
+
// Fire EXACTLY on the FAIL_GRACE-th consecutive failure (a successful
|
|
109
|
+
// frame resets `fails` to 0), so the status is emitted once per
|
|
110
|
+
// failure streak rather than every tick. We surface it whether or not
|
|
111
|
+
// a prior frame exists: with no frame yet the page never came up;
|
|
112
|
+
// with a prior frame the page later died and the pane would otherwise
|
|
113
|
+
// sit frozen on a stale screenshot with no hint the live view is dead.
|
|
114
|
+
if (++fails === FAIL_GRACE) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
emit({
|
|
117
|
+
type: 'status',
|
|
118
|
+
text: last ? `Browser disconnected: ${message}` : `Browser unavailable: ${message}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
inFlight = false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// After an interaction, grab a frame now and once more shortly after, so a
|
|
127
|
+
// click/keypress that kicks off async work (a navigation, a menu opening,
|
|
128
|
+
// an animation) shows up promptly instead of waiting for the next poll.
|
|
129
|
+
const bump = (): void => {
|
|
130
|
+
void tick();
|
|
131
|
+
setTimeout(() => void tick(), 140);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Kick an immediate frame (launches the browser), then poll.
|
|
135
|
+
startPolling();
|
|
136
|
+
|
|
137
|
+
const runInstall = async (): Promise<void> => {
|
|
138
|
+
if (installing) return;
|
|
139
|
+
installing = true;
|
|
140
|
+
stopPolling();
|
|
141
|
+
emit({ type: 'status', text: 'Installing browser engine… (one-time, ~200MB)' });
|
|
142
|
+
try {
|
|
143
|
+
await installPlaywrightPackage({
|
|
144
|
+
rootDir: resolveBrowserInstallRoot(deps),
|
|
145
|
+
onProgress: (line) => emit({ type: 'status', text: line }),
|
|
146
|
+
});
|
|
147
|
+
// The sidecar cached its failed `import('playwright')`; drop it so the
|
|
148
|
+
// next frame call respawns a fresh sidecar that imports the now-present
|
|
149
|
+
// package.
|
|
150
|
+
await closeBrowserSidecar();
|
|
151
|
+
needsInstall = false;
|
|
152
|
+
fails = 0;
|
|
153
|
+
emit({ type: 'status', text: 'Installed. Starting browser…' });
|
|
154
|
+
startPolling();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
emit({ type: 'status', needsInstall: true, text: `Install failed: ${message}` });
|
|
158
|
+
} finally {
|
|
159
|
+
installing = false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
id: 'browser',
|
|
165
|
+
kind: 'browser',
|
|
166
|
+
onData: (cb) => {
|
|
167
|
+
dataSubs.add(cb);
|
|
168
|
+
return () => dataSubs.delete(cb);
|
|
169
|
+
},
|
|
170
|
+
snapshot: () =>
|
|
171
|
+
needsInstall
|
|
172
|
+
? {
|
|
173
|
+
type: 'status',
|
|
174
|
+
needsInstall: true,
|
|
175
|
+
text: 'The browser engine (Playwright) is not installed. It is a one-time ~200MB download.',
|
|
176
|
+
}
|
|
177
|
+
: last
|
|
178
|
+
? { type: 'frame', base64: last.base64, mime: last.mediaType, url: last.url }
|
|
179
|
+
: { type: 'status', text: 'Starting browser…' },
|
|
180
|
+
resize: async (size) => {
|
|
181
|
+
// Match the page viewport to the pane so the live view fills the
|
|
182
|
+
// container (no letterboxing) and click coords map 1:1.
|
|
183
|
+
if (!size.width || !size.height) return;
|
|
184
|
+
await browserSidecarCall('setviewport', { width: size.width, height: size.height }, deps).catch(
|
|
185
|
+
() => undefined,
|
|
186
|
+
);
|
|
187
|
+
void tick();
|
|
188
|
+
},
|
|
189
|
+
input: async (msg) => {
|
|
190
|
+
if (msg.type === 'install') {
|
|
191
|
+
await runInstall();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const vw = last?.width ?? 1280;
|
|
195
|
+
const vh = last?.height ?? 720;
|
|
196
|
+
if (msg.type === 'navigate' && typeof msg.url === 'string') {
|
|
197
|
+
// The sidecar's goto re-runs the SSRF guard (loopback/private/
|
|
198
|
+
// metadata blocked), so a hostile URL never navigates. Unlike the
|
|
199
|
+
// pointer/key proxies below, a navigate rejection is user-meaningful
|
|
200
|
+
// (bad URL format, SSRF block) — surface it as a status line instead
|
|
201
|
+
// of swallowing it, so the user knows why the address bar did nothing.
|
|
202
|
+
try {
|
|
203
|
+
await browserSidecarCall('goto', { url: msg.url }, deps);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
206
|
+
emit({ type: 'status', text });
|
|
207
|
+
}
|
|
208
|
+
bump();
|
|
209
|
+
} else if (
|
|
210
|
+
(msg.type === 'click' || msg.type === 'dblclick') &&
|
|
211
|
+
typeof msg.fx === 'number' &&
|
|
212
|
+
typeof msg.fy === 'number'
|
|
213
|
+
) {
|
|
214
|
+
const count = msg.type === 'dblclick' ? 2 : 1;
|
|
215
|
+
await browserSidecarCall('mouse', { x: msg.fx * vw, y: msg.fy * vh, count }, deps).catch(
|
|
216
|
+
() => undefined,
|
|
217
|
+
);
|
|
218
|
+
bump();
|
|
219
|
+
} else if (msg.type === 'move' && typeof msg.fx === 'number' && typeof msg.fy === 'number') {
|
|
220
|
+
// Hover — drive the pointer so :hover styles/tooltips render. A single
|
|
221
|
+
// follow-up frame (not a bump) keeps the cost down at move frequency.
|
|
222
|
+
await browserSidecarCall('mousemove', { x: msg.fx * vw, y: msg.fy * vh }, deps).catch(
|
|
223
|
+
() => undefined,
|
|
224
|
+
);
|
|
225
|
+
void tick();
|
|
226
|
+
} else if (
|
|
227
|
+
msg.type === 'capture' &&
|
|
228
|
+
typeof msg.fx === 'number' &&
|
|
229
|
+
typeof msg.fy === 'number' &&
|
|
230
|
+
typeof msg.fw === 'number' &&
|
|
231
|
+
typeof msg.fh === 'number'
|
|
232
|
+
) {
|
|
233
|
+
// Sharp PNG of the dragged region → handed back to the pane, which
|
|
234
|
+
// attaches it to the chat composer (the user then describes the change).
|
|
235
|
+
const shot = (await browserSidecarCall(
|
|
236
|
+
'capture',
|
|
237
|
+
{ x: msg.fx * vw, y: msg.fy * vh, width: msg.fw * vw, height: msg.fh * vh },
|
|
238
|
+
deps,
|
|
239
|
+
).catch(() => null)) as unknown;
|
|
240
|
+
// Validate before emitting so a partial/altered reply doesn't hand
|
|
241
|
+
// the composer a {base64: undefined} attachment.
|
|
242
|
+
if (
|
|
243
|
+
shot &&
|
|
244
|
+
typeof shot === 'object' &&
|
|
245
|
+
typeof (shot as { base64?: unknown }).base64 === 'string' &&
|
|
246
|
+
typeof (shot as { mediaType?: unknown }).mediaType === 'string'
|
|
247
|
+
) {
|
|
248
|
+
const s = shot as { base64: string; mediaType: string };
|
|
249
|
+
emit({ type: 'captured', base64: s.base64, mediaType: s.mediaType });
|
|
250
|
+
}
|
|
251
|
+
} else if (msg.type === 'zoom' && typeof msg.factor === 'number') {
|
|
252
|
+
await browserSidecarCall('zoom', { factor: msg.factor }, deps).catch(() => undefined);
|
|
253
|
+
bump();
|
|
254
|
+
} else if (msg.type === 'key' && typeof msg.key === 'string') {
|
|
255
|
+
await browserSidecarCall('key', { key: msg.key }, deps).catch(() => undefined);
|
|
256
|
+
bump();
|
|
257
|
+
} else if (msg.type === 'scroll' && typeof msg.dy === 'number') {
|
|
258
|
+
await browserSidecarCall('scroll', { dy: msg.dy }, deps).catch(() => undefined);
|
|
259
|
+
bump();
|
|
260
|
+
} else if (msg.type === 'back' || msg.type === 'forward' || msg.type === 'reload') {
|
|
261
|
+
await browserSidecarCall(msg.type, {}, deps).catch(() => undefined);
|
|
262
|
+
bump();
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
close: () => {
|
|
266
|
+
if (timer) clearInterval(timer);
|
|
267
|
+
timer = null;
|
|
268
|
+
dataSubs.clear();
|
|
269
|
+
// The underlying page stays alive for the agent's browser_session tool;
|
|
270
|
+
// it's torn down on session shutdown (closeBrowserSidecar).
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|