@assrt-ai/assrt 0.3.3

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.
@@ -0,0 +1,2256 @@
1
+ // src/core/keychain.ts
2
+ import { execSync } from "child_process";
3
+ var KEYCHAIN_SERVICE = "Claude Code-credentials";
4
+ function getCredential() {
5
+ if (process.platform !== "darwin") {
6
+ throw new Error(
7
+ "Assrt CLI currently requires macOS with Claude Code installed.\nLog in to Claude Code (`claude` in terminal) to store credentials in Keychain."
8
+ );
9
+ }
10
+ try {
11
+ const raw = execSync(
12
+ `security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`,
13
+ { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }
14
+ ).trim();
15
+ const parsed = JSON.parse(raw);
16
+ const token = parsed?.claudeAiOauth?.accessToken;
17
+ if (token) {
18
+ console.error("[auth] Using Claude Code OAuth token from macOS Keychain");
19
+ return { token, type: "oauth" };
20
+ }
21
+ } catch {
22
+ }
23
+ const apiKey = process.env.ANTHROPIC_API_KEY;
24
+ if (apiKey) {
25
+ console.error("[auth] Using ANTHROPIC_API_KEY env var");
26
+ return { token: apiKey, type: "apiKey" };
27
+ }
28
+ throw new Error(
29
+ "No credentials found. Either:\n - Log in to Claude Code (`claude` in terminal) to store credentials in Keychain, or\n - Set the ANTHROPIC_API_KEY environment variable."
30
+ );
31
+ }
32
+
33
+ // src/core/freestyle.ts
34
+ import { Agent, fetch as undiciFetch } from "undici";
35
+ import crypto from "crypto";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
37
+ import { join } from "path";
38
+ import { homedir } from "os";
39
+ function flog(level, data) {
40
+ const line = "[freestyle] " + JSON.stringify({ ...data, ts: (/* @__PURE__ */ new Date()).toISOString() });
41
+ console[level](line);
42
+ }
43
+ var freestyleModule = null;
44
+ async function getFreestyle() {
45
+ if (!freestyleModule) {
46
+ freestyleModule = await import("freestyle-sandboxes");
47
+ }
48
+ return freestyleModule;
49
+ }
50
+ var dispatcher = new Agent({
51
+ headersTimeout: 15 * 60 * 1e3,
52
+ bodyTimeout: 15 * 60 * 1e3
53
+ });
54
+ async function buildClient() {
55
+ const { Freestyle } = await getFreestyle();
56
+ const apiKey = process.env.FREESTYLE_API_KEY;
57
+ if (!apiKey) throw new Error("FREESTYLE_API_KEY not configured");
58
+ return new Freestyle({
59
+ apiKey,
60
+ fetch: (url, opts = {}) => (
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ undiciFetch(url, { ...opts, dispatcher })
63
+ )
64
+ });
65
+ }
66
+ var proxyScript = `
67
+ const http = require('http');
68
+ const WS = require('ws');
69
+
70
+ const MCP_PORT = 3001;
71
+ const CDP_PORT = 9222;
72
+
73
+ const fs = require('fs');
74
+ const path = require('path');
75
+ const { execSync } = require('child_process');
76
+
77
+ const FRAMES_DIR = '/tmp/video/frames';
78
+ const VIDEO_FILE = '/tmp/video/recording.webm';
79
+
80
+ // Ensure frames directory exists
81
+ try { fs.mkdirSync(FRAMES_DIR, { recursive: true }); } catch {}
82
+
83
+ // Global frame counter for saving screencast JPEGs
84
+ let frameIndex = 0;
85
+
86
+ const server = http.createServer((req, res) => {
87
+ // POST /video/encode \u2014 combine saved screencast frames into mp4 via ffmpeg
88
+ if (req.url === '/video/encode' && req.method === 'POST') {
89
+ try {
90
+ const files = fs.readdirSync(FRAMES_DIR).filter((f) => f.endsWith('.jpg')).sort();
91
+ if (files.length === 0) { res.writeHead(404); res.end('no frames captured'); return; }
92
+ console.log('[proxy] encoding ' + files.length + ' frames to webm');
93
+ execSync(
94
+ 'ffmpeg -y -framerate 15 -i ' + FRAMES_DIR + '/frame%06d.jpg ' +
95
+ '-c:v libvpx -b:v 1M -pix_fmt yuv420p ' +
96
+ VIDEO_FILE,
97
+ { timeout: 60000, stdio: 'pipe' }
98
+ );
99
+ const stat = fs.statSync(VIDEO_FILE);
100
+ console.log('[proxy] video encoded: ' + stat.size + ' bytes');
101
+ res.writeHead(200, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ ok: true, frames: files.length, sizeBytes: stat.size }));
103
+ } catch (e) {
104
+ console.error('[proxy] encode error:', e.message);
105
+ res.writeHead(500); res.end(e.message);
106
+ }
107
+ return;
108
+ }
109
+ // GET /video \u2014 serve the encoded mp4
110
+ if (req.url === '/video' && req.method === 'GET') {
111
+ try {
112
+ if (!fs.existsSync(VIDEO_FILE)) { res.writeHead(404); res.end('no video'); return; }
113
+ const stat = fs.statSync(VIDEO_FILE);
114
+ res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': stat.size });
115
+ fs.createReadStream(VIDEO_FILE).pipe(res);
116
+ } catch (e) { res.writeHead(500); res.end(e.message); }
117
+ return;
118
+ }
119
+ const proxy = http.request(
120
+ { hostname: '127.0.0.1', port: MCP_PORT, path: req.url, method: req.method, headers: req.headers },
121
+ (pRes) => { res.writeHead(pRes.statusCode, pRes.headers); pRes.pipe(res); }
122
+ );
123
+ req.pipe(proxy);
124
+ proxy.on('error', () => { try { res.writeHead(502); res.end(); } catch {} });
125
+ });
126
+
127
+ const wss = new WS.WebSocketServer({ noServer: true });
128
+
129
+ server.on('upgrade', (req, socket, head) => {
130
+ if (req.url === '/screencast') {
131
+ wss.handleUpgrade(req, socket, head, (ws) => startRelay(ws));
132
+ } else if (req.url === '/input') {
133
+ wss.handleUpgrade(req, socket, head, (ws) => startInputRelay(ws));
134
+ } else if (req.url === '/vnc') {
135
+ // WS-to-WS proxy: accept incoming WebSocket, connect to local websockify, pipe binary data
136
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
137
+ const upstream = new WS('ws://127.0.0.1:5901', ['binary']);
138
+ upstream.binaryType = 'arraybuffer';
139
+ clientWs.binaryType = 'arraybuffer';
140
+
141
+ upstream.on('open', () => {
142
+ console.log('[proxy] VNC WebSocket relay connected');
143
+ });
144
+
145
+ upstream.on('message', (data) => {
146
+ if (clientWs.readyState === 1) clientWs.send(data);
147
+ });
148
+
149
+ clientWs.on('message', (data) => {
150
+ if (upstream.readyState === 1) upstream.send(data);
151
+ });
152
+
153
+ upstream.on('close', () => { if (clientWs.readyState === 1) clientWs.close(); });
154
+ upstream.on('error', () => { if (clientWs.readyState === 1) clientWs.close(); });
155
+ clientWs.on('close', () => { if (upstream.readyState === 1) upstream.close(); });
156
+ clientWs.on('error', () => { if (upstream.readyState === 1) upstream.close(); });
157
+ });
158
+ } else {
159
+ socket.destroy();
160
+ }
161
+ });
162
+
163
+ function startRelay(clientWs) {
164
+ http.get('http://127.0.0.1:' + CDP_PORT + '/json', (res) => {
165
+ let data = '';
166
+ res.on('data', (c) => data += c);
167
+ res.on('end', () => {
168
+ try {
169
+ const targets = JSON.parse(data);
170
+ const page = targets.find((t) => t.type === 'page');
171
+ if (!page || !page.webSocketDebuggerUrl) { clientWs.close(1011, 'no page target'); return; }
172
+ connectCdp(clientWs, page.webSocketDebuggerUrl);
173
+ } catch (e) { clientWs.close(1011, 'json parse error'); }
174
+ });
175
+ }).on('error', () => clientWs.close(1011, 'cdp unreachable'));
176
+ }
177
+
178
+ function connectCdp(clientWs, wsUrl) {
179
+ const cdp = new WS(wsUrl);
180
+ let id = 0;
181
+ let lastFrame = 0;
182
+
183
+ cdp.on('open', () => {
184
+ cdp.send(JSON.stringify({ id: ++id, method: 'Page.enable' }));
185
+ cdp.send(JSON.stringify({
186
+ id: ++id, method: 'Page.startScreencast',
187
+ params: { format: 'jpeg', quality: 60, maxWidth: 1600, maxHeight: 900, everyNthFrame: 2 }
188
+ }));
189
+ console.log('[proxy] CDP screencast started');
190
+ });
191
+
192
+ cdp.on('message', (raw) => {
193
+ try {
194
+ const msg = JSON.parse(raw);
195
+ if (msg.method === 'Page.screencastFrame') {
196
+ cdp.send(JSON.stringify({ id: ++id, method: 'Page.screencastFrameAck', params: { sessionId: msg.params.sessionId } }));
197
+ const now = Date.now();
198
+ if (now - lastFrame < 66) return;
199
+ lastFrame = now;
200
+ // Save frame to disk for video encoding
201
+ const buf = Buffer.from(msg.params.data, 'base64');
202
+ const fname = 'frame' + String(frameIndex++).padStart(6, '0') + '.jpg';
203
+ fs.writeFile(path.join(FRAMES_DIR, fname), buf, () => {});
204
+ // Forward to WebSocket client
205
+ if (clientWs.readyState === 1) {
206
+ clientWs.send(buf);
207
+ }
208
+ }
209
+ } catch {}
210
+ });
211
+
212
+ cdp.on('close', () => { if (clientWs.readyState === 1) clientWs.close(); });
213
+ cdp.on('error', () => { if (clientWs.readyState === 1) clientWs.close(); });
214
+ clientWs.on('close', () => { try { cdp.send(JSON.stringify({ id: ++id, method: 'Page.stopScreencast' })); } catch {} cdp.close(); });
215
+ }
216
+
217
+ function startInputRelay(clientWs) {
218
+ http.get('http://127.0.0.1:' + CDP_PORT + '/json', (res) => {
219
+ let data = '';
220
+ res.on('data', (c) => data += c);
221
+ res.on('end', () => {
222
+ try {
223
+ const targets = JSON.parse(data);
224
+ const page = targets.find((t) => t.type === 'page');
225
+ if (!page || !page.webSocketDebuggerUrl) { clientWs.close(1011, 'no page target'); return; }
226
+ connectInputCdp(clientWs, page.webSocketDebuggerUrl);
227
+ } catch (e) { clientWs.close(1011, 'json parse error'); }
228
+ });
229
+ }).on('error', () => clientWs.close(1011, 'cdp unreachable'));
230
+ }
231
+
232
+ function connectInputCdp(clientWs, wsUrl) {
233
+ const cdp = new WS(wsUrl);
234
+ let id = 1000;
235
+
236
+ cdp.on('open', () => {
237
+ cdp.send(JSON.stringify({ id: ++id, method: 'DOM.enable' }));
238
+ cdp.send(JSON.stringify({ id: ++id, method: 'Overlay.enable' }));
239
+ console.log('[proxy] CDP input relay connected');
240
+ });
241
+
242
+ cdp.on('error', (err) => {
243
+ console.log('[proxy] CDP input error: ' + err.message);
244
+ });
245
+
246
+ cdp.on('message', (raw) => {
247
+ try {
248
+ const msg = JSON.parse(raw);
249
+ if (msg.method === 'Overlay.inspectNodeRequested') {
250
+ clientWs.send(JSON.stringify({ type: 'inspectNode', backendNodeId: msg.params.backendNodeId }));
251
+ }
252
+ } catch {}
253
+ });
254
+
255
+ clientWs.on('message', (raw) => {
256
+ try {
257
+ const msg = JSON.parse(raw);
258
+ if (msg.type === 'mouse') {
259
+ cdp.send(JSON.stringify({
260
+ id: ++id, method: 'Input.dispatchMouseEvent',
261
+ params: { type: msg.action, x: msg.x, y: msg.y, button: msg.button || 'left', clickCount: msg.clickCount || 1 }
262
+ }));
263
+ } else if (msg.type === 'navigate') {
264
+ cdp.send(JSON.stringify({ id: ++id, method: 'Page.navigate', params: { url: msg.url } }));
265
+ } else if (msg.type === 'key') {
266
+ cdp.send(JSON.stringify({
267
+ id: ++id, method: 'Input.dispatchKeyEvent',
268
+ params: { type: msg.action, key: msg.key, code: msg.code, text: msg.text || '', windowsVirtualKeyCode: msg.keyCode || 0, nativeVirtualKeyCode: msg.keyCode || 0 }
269
+ }));
270
+ } else if (msg.type === 'scroll') {
271
+ cdp.send(JSON.stringify({
272
+ id: ++id, method: 'Input.dispatchMouseEvent',
273
+ params: { type: 'mouseWheel', x: msg.x, y: msg.y, deltaX: msg.deltaX || 0, deltaY: msg.deltaY || 0 }
274
+ }));
275
+ } else if (msg.type === 'highlight') {
276
+ if (msg.action === 'inspect') {
277
+ cdp.send(JSON.stringify({
278
+ id: ++id, method: 'Overlay.setInspectMode',
279
+ params: { mode: msg.enabled ? 'searchForNode' : 'none', highlightConfig: { showInfo: true, showStyles: true, showExtensionLines: false, contentColor: { r: 111, g: 168, b: 220, a: 0.66 }, paddingColor: { r: 147, g: 196, b: 125, a: 0.55 }, marginColor: { r: 246, g: 178, b: 107, a: 0.66 } } }
280
+ }));
281
+ } else if (msg.action === 'node' && msg.backendNodeId) {
282
+ cdp.send(JSON.stringify({
283
+ id: ++id, method: 'Overlay.highlightNode',
284
+ params: { backendNodeId: msg.backendNodeId, highlightConfig: { showInfo: true, showStyles: true, contentColor: { r: 111, g: 168, b: 220, a: 0.66 }, paddingColor: { r: 147, g: 196, b: 125, a: 0.55 }, marginColor: { r: 246, g: 178, b: 107, a: 0.66 } } }
285
+ }));
286
+ } else if (msg.action === 'hide') {
287
+ cdp.send(JSON.stringify({ id: ++id, method: 'Overlay.hideHighlight' }));
288
+ }
289
+ }
290
+ } catch {}
291
+ });
292
+
293
+ cdp.on('close', () => { if (clientWs.readyState === 1) clientWs.close(); });
294
+ cdp.on('error', () => { if (clientWs.readyState === 1) clientWs.close(); });
295
+ clientWs.on('close', () => { cdp.close(); });
296
+ }
297
+
298
+ server.listen(3000, '0.0.0.0', () => console.log('[proxy] listening on 3000'));
299
+ `.trim();
300
+ var startupScript = [
301
+ "#!/bin/bash",
302
+ "set -e",
303
+ "",
304
+ "# Start Xvfb virtual display",
305
+ "Xvfb :99 -screen 0 1600x900x24 -ac &",
306
+ "sleep 1",
307
+ "export DISPLAY=:99",
308
+ "",
309
+ "# Start Chromium on the virtual display with remote debugging",
310
+ "chromium --no-sandbox --disable-gpu --disable-software-rasterizer \\",
311
+ " --window-size=1600,900 --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 &",
312
+ "",
313
+ "# Wait for CDP to be ready (up to 30s)",
314
+ "for i in $(seq 1 30); do",
315
+ " if timeout 1 bash -c '</dev/tcp/127.0.0.1/9222' 2>/dev/null; then",
316
+ ' echo "[startup] CDP ready on port 9222"',
317
+ " break",
318
+ " fi",
319
+ " sleep 1",
320
+ "done",
321
+ "",
322
+ "# Start x11vnc on the Xvfb display (no password, listen on port 5900)",
323
+ "x11vnc -display :99 -nopw -forever -shared -rfbport 5900 -q &",
324
+ "",
325
+ "# Start websockify to expose VNC over WebSocket on port 5901",
326
+ "websockify 0.0.0.0:5901 localhost:5900 &",
327
+ "",
328
+ "# Start MCP on port 3001 (behind the proxy)",
329
+ "npx @playwright/mcp --cdp-endpoint http://127.0.0.1:9222 \\",
330
+ " --port 3001 --host 0.0.0.0 --allowed-hosts '*' &",
331
+ "",
332
+ "# Wait for MCP to be ready",
333
+ "for i in $(seq 1 30); do",
334
+ " if timeout 1 bash -c '</dev/tcp/127.0.0.1/3001' 2>/dev/null; then",
335
+ ' echo "[startup] MCP ready on port 3001"',
336
+ " break",
337
+ " fi",
338
+ " sleep 1",
339
+ "done",
340
+ "",
341
+ "# Start the proxy on port 3000 (the externally exposed port)",
342
+ "exec node /opt/proxy.js"
343
+ ].join("\n");
344
+ function buildVmSpec() {
345
+ const { VmSpec, VmBaseImage } = freestyleModule;
346
+ return new VmSpec().baseImage(
347
+ new VmBaseImage("FROM debian:bookworm-slim").runCommands(
348
+ "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates nodejs npm chromium fonts-liberation libgbm1 libnss3 libxss1 ffmpeg xvfb x11vnc websockify && npm install -g @playwright/mcp@0.0.70 ws && node --version && npm --version && which chromium",
349
+ `printf '%s' '${startupScript.replace(/'/g, "'\\''")}' > /opt/startup.sh && chmod +x /opt/startup.sh`,
350
+ `echo '${Buffer.from(proxyScript).toString("base64")}' | base64 -d > /opt/proxy.js`
351
+ )
352
+ ).idleTimeoutSeconds(600).systemdService({
353
+ name: "playwright-mcp",
354
+ mode: "service",
355
+ exec: ["/opt/startup.sh"],
356
+ env: {
357
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: "/usr/bin/chromium",
358
+ NODE_PATH: "/usr/lib/node_modules:/usr/local/lib/node_modules"
359
+ }
360
+ });
361
+ }
362
+ var cachedSnapshotId = null;
363
+ var cachedSpecHash = null;
364
+ function computeSpecHash() {
365
+ return crypto.createHash("sha256").update(startupScript + proxyScript).digest("hex").slice(0, 16);
366
+ }
367
+ function getSnapshotCachePath() {
368
+ const dir = join(homedir(), ".assrt");
369
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
370
+ return join(dir, "snapshots.json");
371
+ }
372
+ function readSnapshotCache() {
373
+ const cachePath = getSnapshotCachePath();
374
+ try {
375
+ if (existsSync(cachePath)) {
376
+ return JSON.parse(readFileSync(cachePath, "utf-8"));
377
+ }
378
+ } catch {
379
+ }
380
+ return {};
381
+ }
382
+ function writeSnapshotCache(cache) {
383
+ try {
384
+ writeFileSync(getSnapshotCachePath(), JSON.stringify(cache, null, 2));
385
+ } catch (err) {
386
+ flog("warn", { event: "freestyle.snapshot.cache_write_error", error: err.message });
387
+ }
388
+ }
389
+ async function readSnapshotFromStore(specHash) {
390
+ try {
391
+ const cache = readSnapshotCache();
392
+ if (cache[specHash]) {
393
+ flog("log", { event: "freestyle.snapshot.cache_hit", specHash, snapshotId: cache[specHash].snapshotId, source: "file" });
394
+ return cache[specHash].snapshotId;
395
+ }
396
+ } catch (err) {
397
+ flog("warn", { event: "freestyle.snapshot.store_read_error", specHash, error: err.message });
398
+ }
399
+ return null;
400
+ }
401
+ async function writeSnapshotToStore(specHash, snapshotId) {
402
+ try {
403
+ const cache = readSnapshotCache();
404
+ cache[specHash] = { snapshotId, created_at: (/* @__PURE__ */ new Date()).toISOString() };
405
+ writeSnapshotCache(cache);
406
+ flog("log", { event: "freestyle.snapshot.store_written", specHash, snapshotId });
407
+ } catch (err) {
408
+ flog("warn", { event: "freestyle.snapshot.store_write_error", specHash, error: err.message });
409
+ }
410
+ }
411
+ async function waitForProxy(vm, vmId, timeoutS) {
412
+ const tWait = Date.now();
413
+ for (let i = 0; i < timeoutS; i++) {
414
+ try {
415
+ const r = await vm.exec("timeout 1 bash -c '</dev/tcp/127.0.0.1/3000' && echo UP || echo DOWN");
416
+ if (r.stdout && r.stdout.includes("UP")) {
417
+ flog("log", { event: "freestyle.proxy.ready", vmId, durationMs: Date.now() - tWait });
418
+ return true;
419
+ }
420
+ if (i > 0 && i % 10 === 0) {
421
+ const ports = await vm.exec(
422
+ "timeout 1 bash -c '</dev/tcp/127.0.0.1/9222' 2>/dev/null && echo 'cdp=UP' || echo 'cdp=DOWN'; timeout 1 bash -c '</dev/tcp/127.0.0.1/3001' 2>/dev/null && echo 'mcp=UP' || echo 'mcp=DOWN'"
423
+ );
424
+ flog("log", { event: "freestyle.proxy.waiting", vmId, elapsedS: i, ports: (ports.stdout || "").trim() });
425
+ }
426
+ } catch {
427
+ }
428
+ await new Promise((r) => setTimeout(r, 1e3));
429
+ }
430
+ flog("warn", { event: "freestyle.proxy.timeout", vmId, durationMs: Date.now() - tWait });
431
+ return false;
432
+ }
433
+ async function buildLiveSnapshot() {
434
+ await getFreestyle();
435
+ const freestyle = await buildClient();
436
+ const spec = buildVmSpec();
437
+ flog("log", { event: "freestyle.snapshot.build_start" });
438
+ const t0 = Date.now();
439
+ const { vmId, vm } = await freestyle.vms.create(spec);
440
+ flog("log", { event: "freestyle.snapshot.vm_created", vmId, durationMs: Date.now() - t0 });
441
+ const ready = await waitForProxy(vm, vmId, 90);
442
+ if (!ready) {
443
+ await freestyle.vms.delete({ vmId }).catch(() => {
444
+ });
445
+ throw new Error("Snapshot source VM proxy never became ready");
446
+ }
447
+ const tSnap = Date.now();
448
+ const snapResult = await vm.snapshot({ name: "assrt-chromium-mcp-ready" });
449
+ const snapshotId = snapResult.snapshotId;
450
+ flog("log", { event: "freestyle.snapshot.created", snapshotId, snapshotDurationMs: Date.now() - tSnap, totalDurationMs: Date.now() - t0 });
451
+ await freestyle.vms.delete({ vmId }).catch(() => {
452
+ });
453
+ return snapshotId;
454
+ }
455
+ async function ensureLiveSnapshot() {
456
+ const specHash = computeSpecHash();
457
+ if (cachedSnapshotId && cachedSpecHash === specHash) {
458
+ flog("log", { event: "freestyle.snapshot.cache_hit", specHash, snapshotId: cachedSnapshotId, source: "memory" });
459
+ return cachedSnapshotId;
460
+ }
461
+ const storedId = await readSnapshotFromStore(specHash);
462
+ if (storedId) {
463
+ cachedSnapshotId = storedId;
464
+ cachedSpecHash = specHash;
465
+ return storedId;
466
+ }
467
+ flog("log", { event: "freestyle.snapshot.cache_miss", specHash });
468
+ const snapshotId = await buildLiveSnapshot();
469
+ cachedSnapshotId = snapshotId;
470
+ cachedSpecHash = specHash;
471
+ await writeSnapshotToStore(specHash, snapshotId);
472
+ return snapshotId;
473
+ }
474
+ async function invalidateSnapshotCache(reason) {
475
+ const specHash = computeSpecHash();
476
+ flog("warn", { event: "freestyle.snapshot.invalidated", specHash, reason, oldSnapshotId: cachedSnapshotId });
477
+ cachedSnapshotId = null;
478
+ cachedSpecHash = null;
479
+ try {
480
+ const cache = readSnapshotCache();
481
+ delete cache[specHash];
482
+ writeSnapshotCache(cache);
483
+ } catch (err) {
484
+ flog("warn", { event: "freestyle.snapshot.invalidate_store_error", error: err.message });
485
+ }
486
+ }
487
+ async function createVmFromSnapshot(freestyle, snapshotId) {
488
+ const client = freestyle;
489
+ const originalPost = client._apiClient.post.bind(client._apiClient);
490
+ let rawCreate = null;
491
+ client._apiClient.post = async (path, ...args) => {
492
+ const r = await originalPost(path, ...args);
493
+ if (path === "/v1/vms") rawCreate = r;
494
+ return r;
495
+ };
496
+ const t0 = Date.now();
497
+ flog("log", { event: "freestyle.vm.create_start", snapshotId });
498
+ const { vmId, vm } = await freestyle.vms.create({
499
+ snapshotId,
500
+ ports: [{ port: 443, targetPort: 3e3 }]
501
+ });
502
+ const host = rawCreate?.domains?.[0];
503
+ const vmCreateMs = Date.now() - t0;
504
+ flog("log", { event: "freestyle.vm.created", vmId, host, durationMs: vmCreateMs, fromSnapshot: true });
505
+ if (!host) {
506
+ try {
507
+ await freestyle.vms.delete({ vmId });
508
+ } catch {
509
+ }
510
+ throw new Error("Freestyle did not return a built-in .vm.freestyle.sh domain");
511
+ }
512
+ await waitForProxy(vm, vmId, 30);
513
+ try {
514
+ const cdpCheck = await vm.exec("timeout 1 bash -c '</dev/tcp/127.0.0.1/9222' && echo UP || echo DOWN");
515
+ flog("log", { event: "freestyle.cdp.check", vmId, status: cdpCheck.stdout?.trim() || "unknown" });
516
+ } catch {
517
+ flog("warn", { event: "freestyle.cdp.check", vmId, status: "failed" });
518
+ }
519
+ return {
520
+ vmId,
521
+ host,
522
+ sseUrl: `https://${host}/sse`,
523
+ screencastUrl: `wss://${host}/screencast`,
524
+ inputUrl: `wss://${host}/input`,
525
+ vncUrl: `wss://${host}/vnc`,
526
+ vm
527
+ };
528
+ }
529
+ async function createTestVm() {
530
+ await getFreestyle();
531
+ const freestyle = await buildClient();
532
+ const snapshotId = await ensureLiveSnapshot();
533
+ try {
534
+ return await createVmFromSnapshot(freestyle, snapshotId);
535
+ } catch (err) {
536
+ const msg = err.message || "";
537
+ if (msg.includes("RESUMED_VM_NON_RESPONSIVE") || msg.includes("not responsive") || msg.includes("INTERNAL_ERROR")) {
538
+ flog("warn", { event: "freestyle.vm.snapshot_stale", snapshotId, error: msg });
539
+ await invalidateSnapshotCache(msg);
540
+ const freshSnapshotId = await ensureLiveSnapshot();
541
+ flog("log", { event: "freestyle.vm.retry_with_fresh_snapshot", freshSnapshotId });
542
+ return await createVmFromSnapshot(freestyle, freshSnapshotId);
543
+ }
544
+ throw err;
545
+ }
546
+ }
547
+ async function destroyTestVm(vmId) {
548
+ try {
549
+ const freestyle = await buildClient();
550
+ await freestyle.vms.delete({ vmId });
551
+ flog("log", { event: "freestyle.vm.deleted", vmId });
552
+ } catch (err) {
553
+ flog("error", { event: "freestyle.vm.delete_failed", vmId, error: err.message });
554
+ }
555
+ }
556
+ function isFreestyleConfigured() {
557
+ return !!process.env.FREESTYLE_API_KEY;
558
+ }
559
+
560
+ // src/core/screencast-remote.ts
561
+ import WebSocket from "ws";
562
+ var CONNECT_TIMEOUT_MS = 3e4;
563
+ var RemoteScreencastSession = class {
564
+ /**
565
+ * @param screencastUrl — WebSocket URL, e.g. `wss://abc.vm.freestyle.sh/screencast`
566
+ * @param onFrame — Callback receiving JPEG buffers for each screencast frame
567
+ */
568
+ constructor(screencastUrl, onFrame) {
569
+ this.ws = null;
570
+ this.running = false;
571
+ this.screencastUrl = screencastUrl;
572
+ this.onFrame = onFrame;
573
+ }
574
+ /** Connect to the proxy's screencast WebSocket and start receiving frames. */
575
+ async start() {
576
+ if (this.running) return;
577
+ console.log(`[screencast] connecting to ${this.screencastUrl} ...`);
578
+ await new Promise((resolve, reject) => {
579
+ const timeout = setTimeout(() => {
580
+ reject(new Error(`Screencast connection timed out after ${CONNECT_TIMEOUT_MS / 1e3}s`));
581
+ ws.close();
582
+ }, CONNECT_TIMEOUT_MS);
583
+ const ws = new WebSocket(this.screencastUrl, {
584
+ rejectUnauthorized: false
585
+ });
586
+ ws.on("open", () => {
587
+ this.ws = ws;
588
+ this.running = true;
589
+ clearTimeout(timeout);
590
+ console.log("[screencast] connected, waiting for frames...");
591
+ resolve();
592
+ });
593
+ ws.on("message", (data) => {
594
+ if (Buffer.isBuffer(data)) {
595
+ this.onFrame(data);
596
+ } else if (data instanceof ArrayBuffer) {
597
+ this.onFrame(Buffer.from(data));
598
+ }
599
+ });
600
+ ws.on("error", (err) => {
601
+ console.error("[screencast] WebSocket error:", err.message);
602
+ if (!this.ws) {
603
+ clearTimeout(timeout);
604
+ reject(err);
605
+ }
606
+ });
607
+ ws.on("close", (code, reason) => {
608
+ this.running = false;
609
+ this.ws = null;
610
+ if (!this.running) {
611
+ console.log(`[screencast] connection closed (code=${code}, reason=${reason})`);
612
+ }
613
+ });
614
+ });
615
+ }
616
+ /** Stop streaming and close the WebSocket. */
617
+ async stop() {
618
+ if (!this.running) return;
619
+ this.running = false;
620
+ if (this.ws) {
621
+ this.ws.close();
622
+ this.ws = null;
623
+ }
624
+ }
625
+ };
626
+
627
+ // src/core/browser.ts
628
+ var CURSOR_INJECT_SCRIPT = `
629
+ if (!window.__pias_cursor_injected) {
630
+ window.__pias_cursor_injected = true;
631
+
632
+ const heartbeat = document.createElement('div');
633
+ heartbeat.id = '__pias_heartbeat';
634
+ Object.assign(heartbeat.style, {
635
+ position: 'fixed', bottom: '8px', right: '8px', width: '6px', height: '6px',
636
+ borderRadius: '50%', background: 'rgba(34,197,94,0.6)', zIndex: '2147483647',
637
+ pointerEvents: 'none',
638
+ });
639
+ heartbeat.animate(
640
+ [{ opacity: 0.2, transform: 'scale(0.8)' }, { opacity: 0.8, transform: 'scale(1.2)' }],
641
+ { duration: 800, iterations: Infinity, direction: 'alternate', easing: 'ease-in-out' }
642
+ );
643
+ document.body.appendChild(heartbeat);
644
+
645
+ const cursor = document.createElement('div');
646
+ cursor.id = '__pias_cursor';
647
+ Object.assign(cursor.style, {
648
+ position: 'fixed', width: '20px', height: '20px', borderRadius: '50%',
649
+ background: 'rgba(239,68,68,0.85)', border: '2px solid white',
650
+ boxShadow: '0 0 8px rgba(239,68,68,0.5)', zIndex: '2147483647',
651
+ pointerEvents: 'none', transition: 'left 0.3s ease, top 0.3s ease',
652
+ left: '-40px', top: '-40px', transform: 'translate(-50%,-50%)',
653
+ });
654
+ document.body.appendChild(cursor);
655
+
656
+ const ripple = document.createElement('div');
657
+ ripple.id = '__pias_ripple';
658
+ Object.assign(ripple.style, {
659
+ position: 'fixed', width: '40px', height: '40px', borderRadius: '50%',
660
+ border: '2px solid rgba(239,68,68,0.6)', zIndex: '2147483646',
661
+ pointerEvents: 'none', opacity: '0', transform: 'translate(-50%,-50%) scale(0.5)',
662
+ left: '-40px', top: '-40px',
663
+ });
664
+ document.body.appendChild(ripple);
665
+
666
+ const toast = document.createElement('div');
667
+ toast.id = '__pias_toast';
668
+ Object.assign(toast.style, {
669
+ position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
670
+ background: 'rgba(0,0,0,0.85)', color: '#22c55e', padding: '8px 16px',
671
+ borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px',
672
+ zIndex: '2147483647', pointerEvents: 'none', opacity: '0',
673
+ transition: 'opacity 0.2s', maxWidth: '80%', whiteSpace: 'nowrap',
674
+ overflow: 'hidden', textOverflow: 'ellipsis', border: '1px solid rgba(34,197,94,0.3)',
675
+ });
676
+ document.body.appendChild(toast);
677
+
678
+ window.__pias_moveCursor = (x, y) => {
679
+ cursor.style.left = x + 'px'; cursor.style.top = y + 'px';
680
+ };
681
+ window.__pias_showClick = (x, y) => {
682
+ cursor.style.left = x + 'px'; cursor.style.top = y + 'px';
683
+ ripple.style.left = x + 'px'; ripple.style.top = y + 'px';
684
+ ripple.style.opacity = '1'; ripple.style.transform = 'translate(-50%,-50%) scale(0.5)';
685
+ setTimeout(() => { ripple.style.transform = 'translate(-50%,-50%) scale(2)'; ripple.style.opacity = '0'; }, 50);
686
+ };
687
+ window.__pias_showToast = (msg) => {
688
+ toast.textContent = msg; toast.style.opacity = '1';
689
+ clearTimeout(window.__pias_toastTimer);
690
+ window.__pias_toastTimer = setTimeout(() => { toast.style.opacity = '0'; }, 2500);
691
+ };
692
+ }
693
+ `;
694
+ var McpBrowserManager = class {
695
+ constructor() {
696
+ this.client = null;
697
+ this.vm = null;
698
+ this.screencast = null;
699
+ // Track cursor position server-side so it persists across navigations
700
+ this.cursorX = 640;
701
+ // Start roughly center-screen
702
+ this.cursorY = 400;
703
+ /** Directory where Playwright saves the video recording (set by launchLocal). */
704
+ this.videoDir = null;
705
+ }
706
+ /** Get the screencast WebSocket URL for direct client connection. */
707
+ get screencastUrl() {
708
+ return this.vm?.screencastUrl ?? null;
709
+ }
710
+ /** Get the input WebSocket URL for user input injection. */
711
+ get inputUrl() {
712
+ return this.vm?.inputUrl ?? null;
713
+ }
714
+ /** Get the VNC WebSocket URL for noVNC connection. */
715
+ get vncUrl() {
716
+ return this.vm?.vncUrl ?? null;
717
+ }
718
+ /** Get the Freestyle VM ID for external lifecycle management. */
719
+ get vmId() {
720
+ return this.vm?.vmId ?? null;
721
+ }
722
+ /** Launch browser in a remote Freestyle VM (production). */
723
+ async launch() {
724
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
725
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
726
+ this.vm = await createTestVm();
727
+ console.log(JSON.stringify({ event: "browser.mcp.connect_start", vmId: this.vm.vmId, sseUrl: this.vm.sseUrl, ts: (/* @__PURE__ */ new Date()).toISOString() }));
728
+ const tConn = Date.now();
729
+ const transport = new SSEClientTransport(new URL(this.vm.sseUrl));
730
+ this.client = new Client(
731
+ { name: "assrt", version: "1.0.0" },
732
+ { capabilities: {} }
733
+ );
734
+ await this.client.connect(transport);
735
+ console.log(JSON.stringify({ event: "browser.mcp.connected", vmId: this.vm.vmId, durationMs: Date.now() - tConn, ts: (/* @__PURE__ */ new Date()).toISOString() }));
736
+ }
737
+ /** Launch browser locally via Playwright MCP over stdio (CLI mode).
738
+ * @param videoDir — Optional directory for Playwright video recording. If provided, a config
739
+ * file is written with recordVideo enabled and passed to the MCP server via --config. */
740
+ async launchLocal(videoDir) {
741
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
742
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
743
+ const { dirname, join: join2 } = await import("path");
744
+ const { tmpdir } = await import("os");
745
+ const { createRequire } = await import("module");
746
+ const require_ = createRequire(import.meta.url);
747
+ const pkgDir = dirname(require_.resolve("@playwright/mcp/package.json"));
748
+ const cliPath = join2(pkgDir, "cli.js");
749
+ console.log("[browser] spawning local Playwright MCP via stdio");
750
+ const tConn = Date.now();
751
+ const userDataDir = join2(tmpdir(), `assrt-browser-${Date.now()}`);
752
+ const args = [cliPath, "--headless", "--viewport-size", "1600x900", "--user-data-dir", userDataDir];
753
+ if (videoDir) {
754
+ const { mkdirSync: mkdirSync2, writeFileSync: writeFileSync2 } = await import("fs");
755
+ mkdirSync2(videoDir, { recursive: true });
756
+ const config = {
757
+ browser: {
758
+ contextOptions: {
759
+ recordVideo: {
760
+ dir: videoDir,
761
+ size: { width: 1600, height: 900 }
762
+ }
763
+ }
764
+ }
765
+ };
766
+ const configPath = join2(tmpdir(), `assrt-pw-config-${Date.now()}.json`);
767
+ writeFileSync2(configPath, JSON.stringify(config));
768
+ args.push("--config", configPath);
769
+ this.videoDir = videoDir;
770
+ console.log(`[browser] video recording enabled \u2192 ${videoDir}`);
771
+ }
772
+ const transport = new StdioClientTransport({
773
+ command: process.execPath,
774
+ args,
775
+ stderr: "pipe"
776
+ });
777
+ this.client = new Client(
778
+ { name: "assrt", version: "1.0.0" },
779
+ { capabilities: {} }
780
+ );
781
+ await this.client.connect(transport);
782
+ console.log(
783
+ `[browser] local MCP connected in ${((Date.now() - tConn) / 1e3).toFixed(1)}s`
784
+ );
785
+ }
786
+ // ── CDP Screencast lifecycle ──
787
+ /**
788
+ * Start streaming JPEG frames from the remote browser via CDP screencast.
789
+ * Connects to the in-VM proxy's /screencast WebSocket endpoint on port 443.
790
+ * Should be called after the first navigation so a page target exists.
791
+ */
792
+ async startScreencast(onFrame) {
793
+ if (this.screencast || !this.vm) return;
794
+ this.screencast = new RemoteScreencastSession(this.vm.screencastUrl, onFrame);
795
+ try {
796
+ await this.screencast.start();
797
+ } catch (err) {
798
+ console.warn("[browser] screencast start failed, falling back to SSE screenshots:", err.message);
799
+ this.screencast = null;
800
+ }
801
+ }
802
+ /** Stop the screencast stream. */
803
+ async stopScreencast() {
804
+ if (this.screencast) {
805
+ await this.screencast.stop();
806
+ this.screencast = null;
807
+ }
808
+ }
809
+ /** Whether the screencast is currently active. */
810
+ get isScreencasting() {
811
+ return this.screencast !== null;
812
+ }
813
+ /** Call a Playwright MCP tool by name. */
814
+ async callTool(name, args = {}) {
815
+ if (!this.client) throw new Error("MCP client not connected");
816
+ const t = Date.now();
817
+ const argSummary = name === "browser_navigate" ? ` url=${args.url}` : name === "browser_type" ? ` text=${JSON.stringify(args.text).slice(0, 40)}` : name === "browser_click" ? ` el=${JSON.stringify(args.element).slice(0, 40)}` : "";
818
+ try {
819
+ const result = await this.client.callTool({
820
+ name,
821
+ arguments: args
822
+ });
823
+ const dt = Date.now() - t;
824
+ const err = result.isError ? " ERROR" : "";
825
+ console.log(`[mcp] ${name}${argSummary} (${dt}ms)${err}`);
826
+ return result;
827
+ } catch (e) {
828
+ console.log(
829
+ `[mcp] ${name}${argSummary} THREW after ${Date.now() - t}ms: ${e.message}`
830
+ );
831
+ throw e;
832
+ }
833
+ }
834
+ // ── Visual overlay helpers ──
835
+ /** Inject cursor + toast overlays into the page (safe to call multiple times).
836
+ * After injection, restores cursor to its last known position instantly
837
+ * (no transition) so it doesn't animate from off-screen. */
838
+ async injectOverlay() {
839
+ try {
840
+ await this.callTool("browser_evaluate", {
841
+ "function": `() => {
842
+ ${CURSOR_INJECT_SCRIPT}
843
+ // Restore cursor to last known position without animation
844
+ const c = document.getElementById('__pias_cursor');
845
+ if (c) {
846
+ c.style.transition = 'none';
847
+ c.style.left = '${this.cursorX}px';
848
+ c.style.top = '${this.cursorY}px';
849
+ // Re-enable smooth transition after a tick
850
+ setTimeout(() => { c.style.transition = 'left 0.3s ease, top 0.3s ease'; }, 50);
851
+ }
852
+ }`
853
+ });
854
+ } catch {
855
+ }
856
+ }
857
+ /** Move cursor smoothly to an element and show click ripple.
858
+ * The cursor glides from its previous position via CSS transition.
859
+ * Updates the tracked position so it persists across navigations. */
860
+ async showClickAt(element, ref) {
861
+ try {
862
+ await this.injectOverlay();
863
+ const sel = JSON.stringify(element);
864
+ const result = await this.callTool("browser_evaluate", {
865
+ "function": `() => {
866
+ const sel = ${sel};
867
+ const selLower = sel.toLowerCase();
868
+ let el = null;
869
+ try { el = document.querySelector(sel); } catch {}
870
+ if (!el) {
871
+ const candidates = document.querySelectorAll('a, button, input, [role="button"], select, textarea, label, [onclick], [href]');
872
+ const words = selLower.split(/\\s+/).filter(w => w.length > 2);
873
+ let bestScore = 0;
874
+ for (const e of candidates) {
875
+ const txt = (e.textContent || '').trim().toLowerCase();
876
+ if (!txt) continue;
877
+ if (txt === selLower) { el = e; break; }
878
+ let score = 0;
879
+ if (txt.includes(selLower)) score = 3;
880
+ else if (selLower.includes(txt) && txt.length > 2) score = 2;
881
+ else {
882
+ const matched = words.filter(w => txt.includes(w)).length;
883
+ if (matched > 0) score = matched / words.length;
884
+ }
885
+ if (score > bestScore) { bestScore = score; el = e; }
886
+ }
887
+ }
888
+ if (el) {
889
+ const r = el.getBoundingClientRect();
890
+ const x = r.left + r.width / 2;
891
+ const y = r.top + r.height / 2;
892
+ window.__pias_showClick?.(x, y);
893
+ return JSON.stringify({ x, y });
894
+ }
895
+ return null;
896
+ }`
897
+ });
898
+ const text = extractText(result);
899
+ if (text) {
900
+ try {
901
+ const parsed = JSON.parse(text.replace(/^.*?(\{.*\}).*$/, "$1"));
902
+ if (parsed && typeof parsed.x === "number") {
903
+ this.cursorX = Math.round(parsed.x);
904
+ this.cursorY = Math.round(parsed.y);
905
+ }
906
+ } catch {
907
+ }
908
+ }
909
+ } catch {
910
+ }
911
+ }
912
+ /** Show a keystroke toast at the bottom of the page. */
913
+ async showKeystroke(label) {
914
+ try {
915
+ await this.injectOverlay();
916
+ await this.callTool("browser_evaluate", {
917
+ "function": `() => { window.__pias_showToast?.(${JSON.stringify(label)}); }`
918
+ });
919
+ } catch {
920
+ }
921
+ }
922
+ // ── Convenience methods mapping to Playwright MCP tools ──
923
+ async navigate(url) {
924
+ const result = await this.callTool("browser_navigate", { url });
925
+ await this.injectOverlay();
926
+ return extractText(result);
927
+ }
928
+ async snapshot() {
929
+ const result = await this.callTool("browser_snapshot");
930
+ return extractText(result);
931
+ }
932
+ async click(element, ref) {
933
+ await this.showClickAt(element, ref);
934
+ await new Promise((r) => setTimeout(r, 400));
935
+ const args = { element };
936
+ if (ref) args.ref = ref;
937
+ const result = await this.callTool("browser_click", args);
938
+ return extractText(result);
939
+ }
940
+ async type(element, text, ref) {
941
+ await this.showClickAt(element, ref);
942
+ await new Promise((r) => setTimeout(r, 400));
943
+ await this.showKeystroke(`\u2328 typing: "${text.slice(0, 40)}${text.length > 40 ? "\u2026" : ""}"`);
944
+ const args = { element, text };
945
+ if (ref) args.ref = ref;
946
+ const result = await this.callTool("browser_type", args);
947
+ return extractText(result);
948
+ }
949
+ async selectOption(element, values) {
950
+ await this.showClickAt(element);
951
+ await new Promise((r) => setTimeout(r, 400));
952
+ const result = await this.callTool("browser_select_option", {
953
+ element,
954
+ values
955
+ });
956
+ return extractText(result);
957
+ }
958
+ async screenshot() {
959
+ const result = await this.callTool("browser_take_screenshot", { type: "jpeg", quality: 50 });
960
+ for (const content of result.content || []) {
961
+ if (content.type === "image") return content.data || null;
962
+ }
963
+ return null;
964
+ }
965
+ async pressKey(key) {
966
+ await this.showKeystroke(`\u2328 key: ${key}`);
967
+ const result = await this.callTool("browser_press_key", { key });
968
+ return extractText(result);
969
+ }
970
+ async scroll(x, y) {
971
+ const result = await this.callTool("browser_scroll", { x, y });
972
+ return extractText(result);
973
+ }
974
+ async waitForText(text, timeout) {
975
+ const args = { text };
976
+ if (timeout) args.timeout = timeout;
977
+ const result = await this.callTool("browser_wait_for", args);
978
+ return extractText(result);
979
+ }
980
+ async evaluate(expression) {
981
+ const fn = expression.includes("=>") ? expression : `() => (${expression})`;
982
+ const result = await this.callTool("browser_evaluate", { "function": fn });
983
+ return extractText(result);
984
+ }
985
+ /** Trigger ffmpeg encoding of captured screencast frames on the VM. */
986
+ async encodeVideo() {
987
+ if (!this.vm) return false;
988
+ const host = this.vm.sseUrl.replace(/\/sse$/, "").replace(/^https?:\/\//, "");
989
+ const encodeUrl = `https://${host}/video/encode`;
990
+ console.log(`[browser] triggering video encode at ${encodeUrl}`);
991
+ try {
992
+ const resp = await fetch(encodeUrl, { method: "POST" });
993
+ if (!resp.ok) {
994
+ const text = await resp.text();
995
+ console.log(`[browser] video encode failed: ${resp.status} ${text}`);
996
+ return false;
997
+ }
998
+ const result = await resp.json();
999
+ console.log(`[browser] video encoded: ${result.frames} frames, ${result.sizeBytes} bytes`);
1000
+ return true;
1001
+ } catch (err) {
1002
+ console.error(`[browser] video encode error:`, err);
1003
+ return false;
1004
+ }
1005
+ }
1006
+ /** Download the encoded video from the remote VM. Call encodeVideo() first. */
1007
+ async getVideoBuffer() {
1008
+ if (!this.vm) return null;
1009
+ const host = this.vm.sseUrl.replace(/\/sse$/, "").replace(/^https?:\/\//, "");
1010
+ const videoUrl = `https://${host}/video`;
1011
+ console.log(`[browser] downloading video from ${videoUrl}`);
1012
+ try {
1013
+ const resp = await fetch(videoUrl);
1014
+ if (!resp.ok) {
1015
+ console.log(`[browser] video download failed: ${resp.status}`);
1016
+ return null;
1017
+ }
1018
+ const arrayBuf = await resp.arrayBuffer();
1019
+ console.log(`[browser] video downloaded: ${arrayBuf.byteLength} bytes`);
1020
+ return Buffer.from(arrayBuf);
1021
+ } catch (err) {
1022
+ console.error(`[browser] video download error:`, err);
1023
+ return null;
1024
+ }
1025
+ }
1026
+ async close(opts) {
1027
+ await this.stopScreencast();
1028
+ if (this.client) {
1029
+ try {
1030
+ await this.callTool("browser_close");
1031
+ } catch {
1032
+ }
1033
+ try {
1034
+ await this.client.close();
1035
+ } catch {
1036
+ }
1037
+ this.client = null;
1038
+ }
1039
+ if (this.vm && !opts?.skipVmDestroy) {
1040
+ const vmId = this.vm.vmId;
1041
+ this.vm = null;
1042
+ console.log(`[browser] close() \u2192 destroying VM ${vmId}`);
1043
+ destroyTestVm(vmId).catch(
1044
+ (err) => console.error(`[browser] failed to destroy VM ${vmId}:`, err)
1045
+ );
1046
+ }
1047
+ }
1048
+ /** Destroy the VM. Call after getVideoBuffer() if you used skipVmDestroy. */
1049
+ async destroyVm() {
1050
+ if (this.vm) {
1051
+ const vmId = this.vm.vmId;
1052
+ this.vm = null;
1053
+ console.log(`[browser] destroyVm() \u2192 destroying VM ${vmId}`);
1054
+ destroyTestVm(vmId).catch(
1055
+ (err) => console.error(`[browser] failed to destroy VM ${vmId}:`, err)
1056
+ );
1057
+ }
1058
+ }
1059
+ };
1060
+ function extractText(result) {
1061
+ return (result.content || []).filter((c) => c.type === "text").map((c) => c.text || "").join("\n");
1062
+ }
1063
+
1064
+ // src/core/agent.ts
1065
+ import Anthropic from "@anthropic-ai/sdk";
1066
+ import { GoogleGenAI, Type } from "@google/genai";
1067
+
1068
+ // src/core/email.ts
1069
+ var BASE = "https://api.internal.temp-mail.io/api/v3";
1070
+ async function fetchJson(url, options) {
1071
+ const res = await fetch(url, { ...options, signal: AbortSignal.timeout(15e3) });
1072
+ const text = await res.text();
1073
+ try {
1074
+ return JSON.parse(text);
1075
+ } catch {
1076
+ throw new Error(`API returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
1077
+ }
1078
+ }
1079
+ var DisposableEmail = class _DisposableEmail {
1080
+ constructor(address, token) {
1081
+ this.address = address;
1082
+ this.token = token;
1083
+ }
1084
+ /** Create a new random disposable email address */
1085
+ static async create() {
1086
+ const data = await fetchJson(
1087
+ `${BASE}/email/new`,
1088
+ {
1089
+ method: "POST",
1090
+ headers: { "Content-Type": "application/json" },
1091
+ body: JSON.stringify({ min_name_length: 10, max_name_length: 10 })
1092
+ }
1093
+ );
1094
+ if (!data.email || !data.token) {
1095
+ throw new Error(`Invalid response: ${JSON.stringify(data)}`);
1096
+ }
1097
+ return new _DisposableEmail(data.email, data.token);
1098
+ }
1099
+ /** Check inbox for messages */
1100
+ async getMessages() {
1101
+ return fetchJson(`${BASE}/email/${this.address}/messages`);
1102
+ }
1103
+ /**
1104
+ * Wait for a new email to arrive.
1105
+ * Polls every `intervalMs` for up to `timeoutMs`.
1106
+ */
1107
+ async waitForEmail(timeoutMs = 6e4, intervalMs = 3e3) {
1108
+ const start = Date.now();
1109
+ while (Date.now() - start < timeoutMs) {
1110
+ const messages = await this.getMessages();
1111
+ if (messages.length > 0) {
1112
+ return messages[messages.length - 1];
1113
+ }
1114
+ await new Promise((r) => setTimeout(r, intervalMs));
1115
+ }
1116
+ return null;
1117
+ }
1118
+ /**
1119
+ * Wait for a verification email and extract the code from it.
1120
+ */
1121
+ async waitForVerificationCode(timeoutMs = 6e4, intervalMs = 3e3) {
1122
+ const email = await this.waitForEmail(timeoutMs, intervalMs);
1123
+ if (!email) return null;
1124
+ let plainText = email.body_text || "";
1125
+ if (!plainText && email.body_html) {
1126
+ plainText = email.body_html.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/&#?\w+;/g, " ").replace(/\s+/g, " ").trim();
1127
+ }
1128
+ const patterns = [
1129
+ /(?:code|Code|CODE)[:\s]+(\d{4,8})/,
1130
+ /(?:verification|Verification)[:\s]+(\d{4,8})/,
1131
+ /(?:OTP|otp)[:\s]+(\d{4,8})/,
1132
+ /(?:pin|PIN|Pin)[:\s]+(\d{4,8})/,
1133
+ /\b(\d{6})\b/,
1134
+ // 6-digit (most common)
1135
+ /\b(\d{4})\b/,
1136
+ // 4-digit
1137
+ /\b(\d{8})\b/
1138
+ // 8-digit
1139
+ ];
1140
+ for (const pattern of patterns) {
1141
+ const match = plainText.match(pattern);
1142
+ if (match) {
1143
+ return {
1144
+ code: match[1],
1145
+ from: email.from,
1146
+ subject: email.subject,
1147
+ body: plainText.slice(0, 500)
1148
+ };
1149
+ }
1150
+ }
1151
+ return {
1152
+ code: "",
1153
+ from: email.from,
1154
+ subject: email.subject,
1155
+ body: plainText.slice(0, 500)
1156
+ };
1157
+ }
1158
+ };
1159
+
1160
+ // src/core/agent.ts
1161
+ var MAX_STEPS_PER_SCENARIO = 60;
1162
+ var MAX_CONVERSATION_TURNS = Infinity;
1163
+ var DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
1164
+ var DEFAULT_GEMINI_MODEL = "gemini-3.1-pro-preview";
1165
+ var TOOLS = [
1166
+ {
1167
+ name: "navigate",
1168
+ description: "Navigate to a URL.",
1169
+ input_schema: {
1170
+ type: "object",
1171
+ properties: { url: { type: "string", description: "URL to navigate to" } },
1172
+ required: ["url"]
1173
+ }
1174
+ },
1175
+ {
1176
+ name: "snapshot",
1177
+ description: "Get the accessibility tree of the current page. Returns elements with [ref=eN] references you can use for click/type. ALWAYS call this before interacting with elements.",
1178
+ input_schema: { type: "object", properties: {} }
1179
+ },
1180
+ {
1181
+ name: "click",
1182
+ description: "Click an element. Use the element description from the snapshot and optionally the ref ID.",
1183
+ input_schema: {
1184
+ type: "object",
1185
+ properties: {
1186
+ element: { type: "string", description: "Human-readable element description, e.g. 'Submit button' or 'Sign In link'" },
1187
+ ref: { type: "string", description: "Exact ref from snapshot, e.g. 'e5'. Preferred when available." }
1188
+ },
1189
+ required: ["element"]
1190
+ }
1191
+ },
1192
+ {
1193
+ name: "type_text",
1194
+ description: "Type text into an input field. Clears existing content first.",
1195
+ input_schema: {
1196
+ type: "object",
1197
+ properties: {
1198
+ element: { type: "string", description: "Human-readable element description" },
1199
+ text: { type: "string", description: "Text to type" },
1200
+ ref: { type: "string", description: "Exact ref from snapshot" }
1201
+ },
1202
+ required: ["element", "text"]
1203
+ }
1204
+ },
1205
+ {
1206
+ name: "select_option",
1207
+ description: "Select an option from a dropdown.",
1208
+ input_schema: {
1209
+ type: "object",
1210
+ properties: {
1211
+ element: { type: "string", description: "Element description" },
1212
+ values: { type: "array", items: { type: "string" }, description: "Values to select" }
1213
+ },
1214
+ required: ["element", "values"]
1215
+ }
1216
+ },
1217
+ {
1218
+ name: "scroll",
1219
+ description: "Scroll the page. Positive y scrolls down, negative scrolls up.",
1220
+ input_schema: {
1221
+ type: "object",
1222
+ properties: {
1223
+ x: { type: "number", description: "Horizontal scroll pixels (default: 0)" },
1224
+ y: { type: "number", description: "Vertical scroll pixels (default: 400 for down, -400 for up)" }
1225
+ },
1226
+ required: ["y"]
1227
+ }
1228
+ },
1229
+ {
1230
+ name: "press_key",
1231
+ description: "Press a keyboard key. E.g. Enter, Tab, Escape.",
1232
+ input_schema: {
1233
+ type: "object",
1234
+ properties: { key: { type: "string", description: "Key to press" } },
1235
+ required: ["key"]
1236
+ }
1237
+ },
1238
+ {
1239
+ name: "wait",
1240
+ description: "Wait for text to appear on the page, or wait a fixed duration.",
1241
+ input_schema: {
1242
+ type: "object",
1243
+ properties: {
1244
+ text: { type: "string", description: "Text to wait for (preferred)" },
1245
+ ms: { type: "number", description: "Milliseconds to wait (fallback, max 10000)" }
1246
+ }
1247
+ }
1248
+ },
1249
+ {
1250
+ name: "screenshot",
1251
+ description: "Take a screenshot of the current page.",
1252
+ input_schema: { type: "object", properties: {} }
1253
+ },
1254
+ {
1255
+ name: "evaluate",
1256
+ description: "Run JavaScript in the browser and return the result.",
1257
+ input_schema: {
1258
+ type: "object",
1259
+ properties: { expression: { type: "string", description: "JavaScript expression to evaluate" } },
1260
+ required: ["expression"]
1261
+ }
1262
+ },
1263
+ {
1264
+ name: "create_temp_email",
1265
+ description: "Create a disposable email address. Use BEFORE filling signup forms.",
1266
+ input_schema: { type: "object", properties: {} }
1267
+ },
1268
+ {
1269
+ name: "wait_for_verification_code",
1270
+ description: "Wait for a verification/OTP code at the disposable email. Polls up to 60s.",
1271
+ input_schema: {
1272
+ type: "object",
1273
+ properties: { timeout_seconds: { type: "number", description: "Max seconds to wait (default 60)" } }
1274
+ }
1275
+ },
1276
+ {
1277
+ name: "check_email_inbox",
1278
+ description: "Check the disposable email inbox.",
1279
+ input_schema: { type: "object", properties: {} }
1280
+ },
1281
+ {
1282
+ name: "assert",
1283
+ description: "Make a test assertion about the current page state.",
1284
+ input_schema: {
1285
+ type: "object",
1286
+ properties: {
1287
+ description: { type: "string", description: "What you are asserting" },
1288
+ passed: { type: "boolean", description: "Whether the assertion passed" },
1289
+ evidence: { type: "string", description: "Evidence for the result" }
1290
+ },
1291
+ required: ["description", "passed", "evidence"]
1292
+ }
1293
+ },
1294
+ {
1295
+ name: "complete_scenario",
1296
+ description: "Mark the current test scenario as complete.",
1297
+ input_schema: {
1298
+ type: "object",
1299
+ properties: {
1300
+ summary: { type: "string", description: "Summary of what was tested" },
1301
+ passed: { type: "boolean", description: "Whether the scenario passed overall" }
1302
+ },
1303
+ required: ["summary", "passed"]
1304
+ }
1305
+ },
1306
+ {
1307
+ name: "suggest_improvement",
1308
+ description: "Report an obvious bug or UX issue in the application.",
1309
+ input_schema: {
1310
+ type: "object",
1311
+ properties: {
1312
+ title: { type: "string", description: "Short title" },
1313
+ severity: { type: "string", description: "critical, major, or minor" },
1314
+ description: { type: "string", description: "What is wrong" },
1315
+ suggestion: { type: "string", description: "How to fix it" }
1316
+ },
1317
+ required: ["title", "severity", "description", "suggestion"]
1318
+ }
1319
+ },
1320
+ {
1321
+ name: "http_request",
1322
+ description: "Make an HTTP request to an external API. Use for verifying webhooks, polling APIs (Telegram, Slack, GitHub), or any external service interaction.",
1323
+ input_schema: {
1324
+ type: "object",
1325
+ properties: {
1326
+ url: { type: "string", description: "Full URL to request" },
1327
+ method: { type: "string", description: "HTTP method: GET, POST, PUT, DELETE (default: GET)" },
1328
+ headers: { type: "object", description: "Request headers as key-value pairs" },
1329
+ body: { type: "string", description: "Request body (JSON string for POST/PUT)" }
1330
+ },
1331
+ required: ["url"]
1332
+ }
1333
+ },
1334
+ {
1335
+ name: "wait_for_stable",
1336
+ description: "Wait until the page content stops changing (no new DOM mutations for the specified stable period). Use after triggering async actions like chat AI responses, loading states, or search results populating.",
1337
+ input_schema: {
1338
+ type: "object",
1339
+ properties: {
1340
+ timeout_seconds: { type: "number", description: "Max seconds to wait (default 30)" },
1341
+ stable_seconds: { type: "number", description: "Seconds of no DOM changes to consider stable (default 2)" }
1342
+ }
1343
+ }
1344
+ }
1345
+ ];
1346
+ var SYSTEM_PROMPT = `You are an automated web testing agent called Assrt. Your job is to systematically test web applications by executing test scenarios.
1347
+
1348
+ ## How You Work
1349
+ 1. You receive test scenario(s) to execute on a web application
1350
+ 2. You interact with the page using the provided tools
1351
+ 3. You verify expected behavior using assertions
1352
+ 4. You report results clearly
1353
+
1354
+ ## CRITICAL Rules
1355
+ - ALWAYS call snapshot FIRST to get the accessibility tree with element refs
1356
+ - Use the ref IDs from snapshots (e.g. ref="e5") when clicking or typing. This is faster and more reliable than text matching.
1357
+ - After each action, call snapshot again to see the updated page state
1358
+ - Make assertions to verify expected behavior (use the assert tool)
1359
+ - Call complete_scenario when done
1360
+
1361
+ ## Selector Strategy (Playwright MCP refs)
1362
+ 1. Call snapshot to get the accessibility tree
1363
+ 2. Find the element you want to interact with in the tree
1364
+ 3. Use its ref value (e.g. "e5") in the ref parameter of click/type_text
1365
+ 4. Also provide a human-readable element description for logging
1366
+ 5. If a ref is stale (action fails), call snapshot again to get fresh refs
1367
+
1368
+ ## Error Recovery
1369
+ When an action fails:
1370
+ 1. Call snapshot to see what is currently on the page
1371
+ 2. The page may have changed (modal appeared, navigation happened)
1372
+ 3. Try using a different ref or approach
1373
+ 4. If stuck after 3 attempts, scroll and retry
1374
+ 5. If truly stuck, mark as failed with evidence and call complete_scenario
1375
+
1376
+ ## Email Verification Strategy
1377
+ When you encounter a login/signup form that requires an email:
1378
+ 1. FIRST call create_temp_email to get a disposable email
1379
+ 2. Use THAT email in the signup form
1380
+ 3. After submitting, call wait_for_verification_code for the OTP
1381
+ 4. Enter the verification code into the form
1382
+ - If the code input is split across multiple single-character fields (common OTP pattern), do NOT type into each field individually. Instead, find the parent container of the input fields in the snapshot (the generic element that wraps all the textboxes), then use evaluate on that container to dispatch a paste event:
1383
+ \`(el) => { const dt = new DataTransfer(); dt.setData('text/plain', 'CODE_HERE'); el.dispatchEvent(new ClipboardEvent('paste', {clipboardData: dt, bubbles: true, cancelable: true})); return 'pasted'; }\`
1384
+ Pass the container's ref to evaluate so it receives the element. Replace CODE_HERE with the actual code. Then call snapshot to verify all digits filled correctly. This is MUCH faster than typing into 6 fields one at a time.
1385
+
1386
+ ## Scenario Continuity
1387
+ - Scenarios run in the SAME browser session
1388
+ - Cookies, auth state carry over between scenarios
1389
+ - Take advantage of existing state rather than starting from scratch
1390
+
1391
+ ## External API Verification
1392
+ When testing integrations (Telegram, Slack, GitHub, etc.):
1393
+ 1. Use http_request to call external APIs (e.g. poll Telegram Bot API for messages)
1394
+ 2. This lets you verify that actions in the web app produced the expected external effect
1395
+ 3. Example: after connecting Telegram in a web app, use http_request to call https://api.telegram.org/bot<token>/getUpdates to verify messages arrived
1396
+
1397
+ ## Waiting for Async Content
1398
+ When the page has loading states, streaming AI responses, or async content:
1399
+ 1. Use wait_for_stable to wait until the DOM stops changing
1400
+ 2. This is better than wait with a fixed time because it adapts to actual load speed
1401
+ 3. Use it after submitting forms, sending chat messages, or triggering any async operation
1402
+ 4. Then call snapshot to see the final state`;
1403
+ var DISCOVERY_SYSTEM_PROMPT = `You are a QA engineer generating quick test cases for an AI browser agent that just landed on a new page. The agent can click, type, scroll, and verify visible text.
1404
+
1405
+ ## Output Format
1406
+ #Case 1: [short name]
1407
+ [1-2 lines: what to click/type and what to verify]
1408
+
1409
+ ## Rules
1410
+ - Generate only 1-2 cases
1411
+ - Each case must be completable in 3-4 actions max
1412
+ - Reference ACTUAL buttons/links/inputs visible on the page
1413
+ - Do NOT generate login/signup cases
1414
+ - Do NOT generate cases about CSS, responsive layout, or performance`;
1415
+ var MAX_CONCURRENT_DISCOVERIES = 3;
1416
+ var MAX_DISCOVERED_PAGES = 20;
1417
+ var SKIP_URL_PATTERNS = [/\/logout/i, /\/api\//i, /^javascript:/i, /^about:blank/i, /^data:/i, /^chrome/i];
1418
+ var GEMINI_FUNCTION_DECLARATIONS = TOOLS.map((t) => ({
1419
+ name: t.name,
1420
+ description: t.description,
1421
+ parameters: {
1422
+ type: Type.OBJECT,
1423
+ properties: Object.fromEntries(
1424
+ Object.entries(t.input_schema.properties || {}).map(
1425
+ ([k, v]) => {
1426
+ const vTyped = v;
1427
+ let gType;
1428
+ if (vTyped.type === "boolean") gType = Type.BOOLEAN;
1429
+ else if (vTyped.type === "number") gType = Type.NUMBER;
1430
+ else if (vTyped.type === "array") gType = Type.ARRAY;
1431
+ else gType = Type.STRING;
1432
+ const entry = { type: gType, description: vTyped.description || "" };
1433
+ if (vTyped.type === "array" && vTyped.items) {
1434
+ entry.items = { type: Type.STRING };
1435
+ }
1436
+ return [k, entry];
1437
+ }
1438
+ )
1439
+ ),
1440
+ required: t.input_schema.required || []
1441
+ }
1442
+ }));
1443
+ var TestAgent = class {
1444
+ constructor(apiKey, emit, model, provider, broadcastFrame, mode, authType, videoDir) {
1445
+ this.anthropic = null;
1446
+ this.gemini = null;
1447
+ this.tempEmail = null;
1448
+ this.discoveredUrls = /* @__PURE__ */ new Set();
1449
+ this.activeDiscoveries = 0;
1450
+ this.browserBusy = false;
1451
+ this.pendingDiscoveryUrls = [];
1452
+ this.broadcastFrame = null;
1453
+ /**
1454
+ * @param broadcastFrame — Optional callback for CDP screencast frames.
1455
+ * When provided, replaces the old 1.5s screenshot polling with continuous
1456
+ * ~15fps streaming. When absent (e.g., plan generation), falls back to
1457
+ * SSE screenshot emits.
1458
+ * @param mode — "local" spawns a local Playwright MCP over stdio,
1459
+ * "remote" (default) uses a Freestyle VM.
1460
+ */
1461
+ /**
1462
+ * @param authType — "apiKey" for regular API keys (X-Api-Key header),
1463
+ * "oauth" for Claude Code OAuth tokens (Authorization: Bearer + beta header).
1464
+ */
1465
+ /** Directory for video recording output. Only used in local mode. */
1466
+ this.videoDir = null;
1467
+ this.provider = provider === "gemini" ? "gemini" : "anthropic";
1468
+ this.browser = new McpBrowserManager();
1469
+ this.emit = emit;
1470
+ this.broadcastFrame = broadcastFrame || null;
1471
+ this.mode = mode || "remote";
1472
+ this.videoDir = videoDir || null;
1473
+ if (this.provider === "gemini") {
1474
+ this.gemini = new GoogleGenAI({ apiKey });
1475
+ this.model = model || process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL;
1476
+ } else {
1477
+ if (authType === "oauth") {
1478
+ this.anthropic = new Anthropic({
1479
+ authToken: apiKey,
1480
+ defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" }
1481
+ });
1482
+ } else {
1483
+ this.anthropic = new Anthropic({ apiKey });
1484
+ }
1485
+ this.model = model || process.env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL;
1486
+ }
1487
+ }
1488
+ async run(url, scenariosText) {
1489
+ const startTime = Date.now();
1490
+ console.log(JSON.stringify({ event: "agent.run.start", url, mode: this.mode, model: this.model, ts: (/* @__PURE__ */ new Date()).toISOString() }));
1491
+ if (this.mode === "local") {
1492
+ this.emit("status", { message: "Launching local browser..." });
1493
+ await this.browser.launchLocal(this.videoDir || void 0);
1494
+ } else {
1495
+ this.emit("status", { message: "Provisioning cloud browser VM..." });
1496
+ await this.browser.launch();
1497
+ }
1498
+ const launchMs = Date.now() - startTime;
1499
+ console.log(JSON.stringify({ event: "agent.browser.launched", durationMs: launchMs, ts: (/* @__PURE__ */ new Date()).toISOString() }));
1500
+ this.emit("status", { message: "Browser launched via Playwright MCP" });
1501
+ const screencastUrl = this.browser.screencastUrl;
1502
+ if (screencastUrl) {
1503
+ this.emit("screencast_url", { url: screencastUrl });
1504
+ }
1505
+ const inputUrl = this.browser.inputUrl;
1506
+ if (inputUrl) {
1507
+ this.emit("input_url", { url: inputUrl });
1508
+ }
1509
+ const vncUrl = this.browser.vncUrl;
1510
+ if (vncUrl) {
1511
+ this.emit("vnc_url", { url: vncUrl });
1512
+ }
1513
+ const vmId = this.browser.vmId;
1514
+ if (vmId) {
1515
+ this.emit("vm_id", { vmId });
1516
+ }
1517
+ const tNav = Date.now();
1518
+ await this.browser.navigate(url);
1519
+ console.log(JSON.stringify({ event: "agent.navigate.done", url, durationMs: Date.now() - tNav, ts: (/* @__PURE__ */ new Date()).toISOString() }));
1520
+ this.emit("status", { message: `Navigated to ${url}` });
1521
+ this.queueDiscoverPage(url);
1522
+ const scenarios = this.parseScenarios(scenariosText);
1523
+ const results = [];
1524
+ try {
1525
+ for (let i = 0; i < scenarios.length; i++) {
1526
+ const scenario = scenarios[i];
1527
+ this.emit("scenario_start", { index: i, name: scenario.name, total: scenarios.length });
1528
+ try {
1529
+ const result = await this.runScenario(
1530
+ url,
1531
+ scenario.name,
1532
+ scenario.steps,
1533
+ i === 0,
1534
+ results.map((r) => `${r.name}: ${r.passed ? "PASSED" : "FAILED"} \u2014 ${r.summary}`)
1535
+ );
1536
+ results.push(result);
1537
+ this.emit("scenario_complete", { index: i, name: scenario.name, passed: result.passed, summary: result.summary });
1538
+ await this.flushDiscovery().catch(() => {
1539
+ });
1540
+ } catch (scenarioErr) {
1541
+ const errMsg = scenarioErr instanceof Error ? scenarioErr.message : String(scenarioErr);
1542
+ this.emit("reasoning", { text: `Scenario "${scenario.name}" crashed: ${errMsg}. Moving to next scenario.` });
1543
+ const failedResult = {
1544
+ name: scenario.name,
1545
+ passed: false,
1546
+ steps: [],
1547
+ assertions: [],
1548
+ summary: `Error: ${errMsg.slice(0, 200)}`,
1549
+ duration: 0
1550
+ };
1551
+ results.push(failedResult);
1552
+ this.emit("scenario_complete", { index: i, name: scenario.name, passed: false, summary: failedResult.summary });
1553
+ }
1554
+ }
1555
+ } finally {
1556
+ }
1557
+ const report = {
1558
+ url,
1559
+ scenarios: results,
1560
+ totalDuration: Date.now() - startTime,
1561
+ passedCount: results.filter((r) => r.passed).length,
1562
+ failedCount: results.filter((r) => !r.passed).length,
1563
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1564
+ };
1565
+ this.emit("report", report);
1566
+ return report;
1567
+ }
1568
+ /** Encode and download the screencast recording from the VM.
1569
+ * Does NOT destroy the VM (it stays alive for user takeover). */
1570
+ async finalizeVideo() {
1571
+ try {
1572
+ const encoded = await this.browser.encodeVideo();
1573
+ if (!encoded) return null;
1574
+ return await this.browser.getVideoBuffer();
1575
+ } catch (err) {
1576
+ console.error("[agent] finalizeVideo error:", err);
1577
+ return null;
1578
+ }
1579
+ }
1580
+ /** Close the browser. For local mode this finalizes the video recording. */
1581
+ async close() {
1582
+ try {
1583
+ await this.browser.close();
1584
+ } catch {
1585
+ }
1586
+ }
1587
+ /* ── Continuous page discovery ── */
1588
+ normalizeUrl(url) {
1589
+ try {
1590
+ const u = new URL(url);
1591
+ return `${u.origin}${u.pathname}`.replace(/\/$/, "");
1592
+ } catch {
1593
+ return url;
1594
+ }
1595
+ }
1596
+ shouldSkipUrl(url) {
1597
+ return SKIP_URL_PATTERNS.some((p) => p.test(url));
1598
+ }
1599
+ queueDiscoverPage(url) {
1600
+ const normalized = this.normalizeUrl(url);
1601
+ if (this.discoveredUrls.has(normalized)) return;
1602
+ if (this.discoveredUrls.size >= MAX_DISCOVERED_PAGES) return;
1603
+ if (this.shouldSkipUrl(url)) return;
1604
+ this.discoveredUrls.add(normalized);
1605
+ this.pendingDiscoveryUrls.push(normalized);
1606
+ }
1607
+ async flushDiscovery() {
1608
+ if (this.browserBusy || this.pendingDiscoveryUrls.length === 0) return;
1609
+ if (this.activeDiscoveries >= MAX_CONCURRENT_DISCOVERIES) return;
1610
+ const urls = this.pendingDiscoveryUrls.splice(0);
1611
+ for (const normalized of urls) {
1612
+ try {
1613
+ const snapshotText = await this.browser.snapshot();
1614
+ const screenshotData = await this.browser.screenshot();
1615
+ this.emit("page_discovered", { url: normalized, title: "", screenshot: screenshotData });
1616
+ this.activeDiscoveries++;
1617
+ this.generateDiscoveryCases(normalized, snapshotText, screenshotData).catch(() => {
1618
+ }).finally(() => {
1619
+ this.activeDiscoveries--;
1620
+ });
1621
+ break;
1622
+ } catch {
1623
+ }
1624
+ }
1625
+ }
1626
+ async generateDiscoveryCases(url, snapshotText, screenshot) {
1627
+ const prompt = `Analyze this page and generate test cases.
1628
+
1629
+ URL: ${url}
1630
+
1631
+ Accessibility Tree:
1632
+ ${snapshotText.slice(0, 4e3)}`;
1633
+ let fullText = "";
1634
+ if (this.provider === "gemini" && this.gemini) {
1635
+ const parts = [];
1636
+ if (screenshot) parts.push({ inlineData: { mimeType: "image/jpeg", data: screenshot } });
1637
+ parts.push({ text: prompt });
1638
+ const response = await this.gemini.models.generateContentStream({
1639
+ model: this.model,
1640
+ contents: [{ role: "user", parts }],
1641
+ config: { systemInstruction: DISCOVERY_SYSTEM_PROMPT }
1642
+ });
1643
+ for await (const chunk of response) {
1644
+ const text = chunk.text || "";
1645
+ if (text) {
1646
+ fullText += text;
1647
+ this.emit("discovered_cases_chunk", { url, text: fullText });
1648
+ }
1649
+ }
1650
+ } else if (this.anthropic) {
1651
+ const content = [];
1652
+ if (screenshot) content.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: screenshot } });
1653
+ content.push({ type: "text", text: prompt });
1654
+ const stream = this.anthropic.messages.stream({
1655
+ model: this.model,
1656
+ max_tokens: 1024,
1657
+ system: DISCOVERY_SYSTEM_PROMPT,
1658
+ messages: [{ role: "user", content }]
1659
+ });
1660
+ for await (const event of stream) {
1661
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
1662
+ fullText += event.delta.text;
1663
+ this.emit("discovered_cases_chunk", { url, text: fullText });
1664
+ }
1665
+ }
1666
+ }
1667
+ this.emit("discovered_cases_complete", { url, cases: fullText });
1668
+ }
1669
+ parseScenarios(text) {
1670
+ const scenarioRegex = /(?:#?\s*(?:Scenario|Test|Case))\s*\d*[:.]\s*/gi;
1671
+ const parts = text.split(scenarioRegex).filter((s) => s.trim());
1672
+ if (parts.length > 1) {
1673
+ const names = text.match(scenarioRegex) || [];
1674
+ return parts.map((steps, i) => ({
1675
+ name: (names[i] || `Case ${i + 1}`).replace(/^#\s*/, "").replace(/[:.]\s*$/, "").trim(),
1676
+ steps: steps.trim()
1677
+ }));
1678
+ }
1679
+ return [{ name: "Test Scenario", steps: text.trim() }];
1680
+ }
1681
+ async runScenario(baseUrl, scenarioName, scenarioSteps, isFirstScenario, previousSummaries) {
1682
+ const startTime = Date.now();
1683
+ const steps = [];
1684
+ const assertions = [];
1685
+ let stepCounter = 0;
1686
+ let completed = false;
1687
+ let scenarioSummary = "";
1688
+ let scenarioPassed = true;
1689
+ const initialSnapshot = await this.browser.snapshot();
1690
+ const initialScreenshot = await this.browser.screenshot();
1691
+ if (initialScreenshot) {
1692
+ console.log(JSON.stringify({ event: "agent.screenshot.emit", size: initialScreenshot.length, ts: (/* @__PURE__ */ new Date()).toISOString() }));
1693
+ this.emit("screenshot", { base64: initialScreenshot });
1694
+ }
1695
+ if (this.broadcastFrame && this.mode !== "local") {
1696
+ await this.browser.startScreencast(this.broadcastFrame);
1697
+ }
1698
+ let contextInfo = "";
1699
+ if (!isFirstScenario && previousSummaries.length > 0) {
1700
+ contextInfo = `
1701
+
1702
+ Previous Scenarios (browser state carries over):
1703
+ ${previousSummaries.map((s, i) => `${i + 1}. ${s}`).join("\n")}`;
1704
+ } else {
1705
+ contextInfo = `
1706
+ Navigated to: ${baseUrl}`;
1707
+ }
1708
+ const emailInfo = this.tempEmail ? `
1709
+ Active disposable email: ${this.tempEmail.address}` : "";
1710
+ const userPrompt = `${contextInfo}${emailInfo}
1711
+
1712
+ Current page accessibility tree:
1713
+ ${initialSnapshot}
1714
+
1715
+ ---
1716
+ Execute this test scenario:
1717
+ **${scenarioName}**
1718
+ ${scenarioSteps}
1719
+
1720
+ IMPORTANT: Use snapshot refs (e.g. ref="e5") for reliable element targeting. Call snapshot before each interaction to get fresh refs.
1721
+ If login/signup needs email, use create_temp_email first.
1722
+
1723
+ Analyze the page, act, make assertions, call complete_scenario when done.`;
1724
+ const messages = this.provider === "gemini" ? [{ role: "user", parts: [
1725
+ ...initialScreenshot ? [{ inlineData: { mimeType: "image/jpeg", data: initialScreenshot } }] : [],
1726
+ { text: userPrompt }
1727
+ ] }] : [{ role: "user", content: [
1728
+ ...initialScreenshot ? [{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: initialScreenshot } }] : [],
1729
+ { type: "text", text: userPrompt }
1730
+ ] }];
1731
+ while (!completed && stepCounter < MAX_STEPS_PER_SCENARIO) {
1732
+ let toolCalls = [];
1733
+ for (let attempt = 0; attempt < 4; attempt++) {
1734
+ try {
1735
+ if (this.provider === "gemini" && this.gemini) {
1736
+ const response = await this.gemini.models.generateContent({
1737
+ model: this.model,
1738
+ contents: messages,
1739
+ config: { tools: [{ functionDeclarations: GEMINI_FUNCTION_DECLARATIONS }], systemInstruction: SYSTEM_PROMPT }
1740
+ });
1741
+ const candidate = response?.candidates?.[0];
1742
+ if (!candidate?.content?.parts) break;
1743
+ const parts = candidate.content.parts;
1744
+ messages.push({ role: "model", parts });
1745
+ for (const p of parts) {
1746
+ if (p.text?.trim()) this.emit("reasoning", { text: p.text });
1747
+ }
1748
+ toolCalls = parts.filter((p) => p.functionCall).map((p) => ({
1749
+ id: `gemini_${Date.now()}_${Math.random()}`,
1750
+ name: p.functionCall.name,
1751
+ args: p.functionCall.args || {}
1752
+ }));
1753
+ } else if (this.anthropic) {
1754
+ const response = await this.anthropic.messages.create({
1755
+ model: this.model,
1756
+ max_tokens: 4096,
1757
+ system: SYSTEM_PROMPT,
1758
+ tools: TOOLS,
1759
+ messages
1760
+ });
1761
+ const content = response.content;
1762
+ for (const b of content) {
1763
+ if (b.type === "text" && b.text.trim()) this.emit("reasoning", { text: b.text });
1764
+ }
1765
+ messages.push({ role: "assistant", content });
1766
+ toolCalls = content.filter((b) => b.type === "tool_use").map((b) => ({
1767
+ id: b.id,
1768
+ name: b.name,
1769
+ args: b.input
1770
+ }));
1771
+ if (toolCalls.length === 0 && response.stop_reason === "end_turn") {
1772
+ completed = true;
1773
+ }
1774
+ }
1775
+ break;
1776
+ } catch (err) {
1777
+ const msg = err instanceof Error ? err.message : String(err);
1778
+ const isRetryable = /529|429|503|overloaded|rate/i.test(msg);
1779
+ if (isRetryable && attempt < 3) {
1780
+ const delay = (attempt + 1) * 5e3;
1781
+ this.emit("reasoning", { text: `API busy (attempt ${attempt + 1}/4), retrying in ${delay / 1e3}s...` });
1782
+ await new Promise((r) => setTimeout(r, delay));
1783
+ continue;
1784
+ }
1785
+ const isFatal = /tool_use|tool_result|invalid_request/i.test(msg);
1786
+ if (isFatal) {
1787
+ this.emit("reasoning", { text: `API error, ending scenario: ${msg.slice(0, 200)}` });
1788
+ scenarioPassed = false;
1789
+ scenarioSummary = `API error: ${msg.slice(0, 200)}`;
1790
+ completed = true;
1791
+ break;
1792
+ }
1793
+ throw err;
1794
+ }
1795
+ }
1796
+ if (toolCalls.length === 0) break;
1797
+ const toolResults = [];
1798
+ for (const toolCall of toolCalls) {
1799
+ this.browserBusy = true;
1800
+ const toolInput = toolCall.args;
1801
+ stepCounter++;
1802
+ let result = "";
1803
+ let stepAction = toolCall.name;
1804
+ let stepDescription = "";
1805
+ let stepStatus = "completed";
1806
+ this.emit("step", { id: stepCounter, action: toolCall.name, description: `Executing ${toolCall.name}...`, status: "running", timestamp: Date.now() });
1807
+ try {
1808
+ switch (toolCall.name) {
1809
+ case "navigate": {
1810
+ let navUrl = toolInput.url || "";
1811
+ if (navUrl.startsWith("/")) {
1812
+ const urlObj = new URL(baseUrl.startsWith("http") ? baseUrl : `https://${baseUrl}`);
1813
+ navUrl = `${urlObj.origin}${navUrl}`;
1814
+ }
1815
+ result = await this.browser.navigate(navUrl);
1816
+ stepDescription = `Navigate to ${navUrl}`;
1817
+ this.queueDiscoverPage(navUrl);
1818
+ break;
1819
+ }
1820
+ case "snapshot": {
1821
+ result = await this.browser.snapshot();
1822
+ stepDescription = "Get page snapshot (accessibility tree)";
1823
+ stepAction = "inspect";
1824
+ break;
1825
+ }
1826
+ case "click": {
1827
+ const element = toolInput.element;
1828
+ const ref = toolInput.ref;
1829
+ result = await this.browser.click(element, ref);
1830
+ stepDescription = `Click "${element}"${ref ? ` [ref=${ref}]` : ""}`;
1831
+ break;
1832
+ }
1833
+ case "type_text": {
1834
+ const element = toolInput.element;
1835
+ const text = toolInput.text;
1836
+ const ref = toolInput.ref;
1837
+ result = await this.browser.type(element, text, ref);
1838
+ stepDescription = `Type "${text}" into "${element}"`;
1839
+ break;
1840
+ }
1841
+ case "select_option": {
1842
+ const element = toolInput.element;
1843
+ const values = toolInput.values || [toolInput.value];
1844
+ result = await this.browser.selectOption(element, values);
1845
+ stepDescription = `Select "${values.join(", ")}" in "${element}"`;
1846
+ break;
1847
+ }
1848
+ case "scroll": {
1849
+ const x = toolInput.x || 0;
1850
+ const y = toolInput.y || 400;
1851
+ result = await this.browser.scroll(x, y);
1852
+ stepDescription = `Scroll ${y > 0 ? "down" : "up"} by ${Math.abs(y)}px`;
1853
+ break;
1854
+ }
1855
+ case "press_key": {
1856
+ const key = toolInput.key;
1857
+ result = await this.browser.pressKey(key);
1858
+ stepDescription = `Press ${key}`;
1859
+ break;
1860
+ }
1861
+ case "wait": {
1862
+ if (toolInput.text) {
1863
+ result = await this.browser.waitForText(toolInput.text, toolInput.ms || 1e4);
1864
+ stepDescription = `Wait for text "${toolInput.text}"`;
1865
+ } else {
1866
+ const ms = Math.min(toolInput.ms || 1e3, 1e4);
1867
+ await new Promise((r) => setTimeout(r, ms));
1868
+ result = `Waited ${ms}ms`;
1869
+ stepDescription = `Wait ${ms}ms`;
1870
+ }
1871
+ break;
1872
+ }
1873
+ case "screenshot": {
1874
+ const data = await this.browser.screenshot();
1875
+ if (data) {
1876
+ this.emit("screenshot", { base64: data });
1877
+ result = "Screenshot taken and sent to client.";
1878
+ } else {
1879
+ result = "Screenshot failed.";
1880
+ }
1881
+ stepDescription = "Take screenshot";
1882
+ stepAction = "inspect";
1883
+ break;
1884
+ }
1885
+ case "evaluate": {
1886
+ const expr = toolInput.expression;
1887
+ result = await this.browser.evaluate(expr);
1888
+ stepDescription = `Evaluate JS: ${expr.slice(0, 80)}`;
1889
+ stepAction = "inspect";
1890
+ break;
1891
+ }
1892
+ case "create_temp_email": {
1893
+ this.tempEmail = await DisposableEmail.create();
1894
+ result = `Created disposable email: ${this.tempEmail.address}
1895
+ Use this EXACT email in signup forms.`;
1896
+ stepDescription = `Created temp email: ${this.tempEmail.address}`;
1897
+ stepAction = "email";
1898
+ this.emit("reasoning", { text: `Disposable email created: ${this.tempEmail.address}` });
1899
+ break;
1900
+ }
1901
+ case "wait_for_verification_code": {
1902
+ if (!this.tempEmail) {
1903
+ result = "Error: Call create_temp_email first.";
1904
+ stepStatus = "failed";
1905
+ stepDescription = "No temp email";
1906
+ break;
1907
+ }
1908
+ const timeout = Math.min((toolInput.timeout_seconds || 60) * 1e3, 12e4);
1909
+ stepDescription = `Waiting for code at ${this.tempEmail.address}...`;
1910
+ this.emit("step", { id: stepCounter, action: "email", description: stepDescription, status: "running", timestamp: Date.now() });
1911
+ const cr = await this.tempEmail.waitForVerificationCode(timeout, 3e3);
1912
+ if (cr?.code) {
1913
+ result = `Verification code received: ${cr.code}
1914
+ From: ${cr.from}
1915
+ Subject: ${cr.subject}`;
1916
+ stepDescription = `Received code: ${cr.code}`;
1917
+ stepAction = "email";
1918
+ } else if (cr) {
1919
+ result = `Email received but no code pattern found.
1920
+ From: ${cr.from}
1921
+ Subject: ${cr.subject}
1922
+ Body: ${cr.body}`;
1923
+ stepDescription = "Email received, manual extraction needed";
1924
+ stepAction = "email";
1925
+ } else {
1926
+ result = `No email within ${timeout / 1e3}s.`;
1927
+ stepStatus = "failed";
1928
+ stepDescription = "Verification email timeout";
1929
+ stepAction = "email";
1930
+ }
1931
+ break;
1932
+ }
1933
+ case "check_email_inbox": {
1934
+ if (!this.tempEmail) {
1935
+ result = "Error: Call create_temp_email first.";
1936
+ stepStatus = "failed";
1937
+ stepDescription = "No temp email";
1938
+ break;
1939
+ }
1940
+ const msgs = await this.tempEmail.getMessages();
1941
+ if (msgs.length === 0) {
1942
+ result = `Inbox empty for ${this.tempEmail.address}.`;
1943
+ } else {
1944
+ const latest = msgs[msgs.length - 1];
1945
+ const body = (latest.body_text || latest.body_html || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
1946
+ result = `${msgs.length} email(s). Latest from: ${latest.from} | Subject: ${latest.subject}
1947
+ Body: ${body.slice(0, 500)}`;
1948
+ }
1949
+ stepDescription = `Check inbox (${msgs.length} emails)`;
1950
+ stepAction = "email";
1951
+ break;
1952
+ }
1953
+ case "assert": {
1954
+ const desc = toolInput.description;
1955
+ const passed = toolInput.passed;
1956
+ const evidence = toolInput.evidence;
1957
+ assertions.push({ description: desc, passed, evidence });
1958
+ result = `Assertion ${passed ? "PASSED" : "FAILED"}: ${desc} \u2014 ${evidence}`;
1959
+ stepDescription = `Assert: ${desc}`;
1960
+ stepAction = passed ? "assert_pass" : "assert_fail";
1961
+ if (!passed) scenarioPassed = false;
1962
+ this.emit("assertion", { description: desc, passed, evidence });
1963
+ break;
1964
+ }
1965
+ case "complete_scenario": {
1966
+ scenarioSummary = toolInput.summary;
1967
+ scenarioPassed = toolInput.passed;
1968
+ completed = true;
1969
+ result = "Scenario complete";
1970
+ stepDescription = "Scenario complete";
1971
+ stepAction = "complete";
1972
+ break;
1973
+ }
1974
+ case "suggest_improvement": {
1975
+ const title = toolInput.title;
1976
+ const severity = toolInput.severity;
1977
+ const desc = toolInput.description;
1978
+ const suggestion = toolInput.suggestion;
1979
+ this.emit("improvement_suggestion", { title, severity, description: desc, suggestion });
1980
+ result = `Improvement logged: ${title}`;
1981
+ stepDescription = `Issue: ${title}`;
1982
+ stepAction = "suggestion";
1983
+ break;
1984
+ }
1985
+ case "http_request": {
1986
+ const reqUrl = toolInput.url;
1987
+ const method = (toolInput.method || "GET").toUpperCase();
1988
+ const headers = toolInput.headers || {};
1989
+ const body = toolInput.body;
1990
+ try {
1991
+ const fetchOptions = {
1992
+ method,
1993
+ headers: { "Content-Type": "application/json", ...headers }
1994
+ };
1995
+ if (body && method !== "GET" && method !== "HEAD") {
1996
+ fetchOptions.body = body;
1997
+ }
1998
+ const controller = new AbortController();
1999
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
2000
+ fetchOptions.signal = controller.signal;
2001
+ const resp = await fetch(reqUrl, fetchOptions);
2002
+ clearTimeout(timeoutId);
2003
+ const respText = await resp.text();
2004
+ const truncated = respText.length > 4e3 ? respText.slice(0, 4e3) + "\n...(truncated)" : respText;
2005
+ result = `HTTP ${resp.status} ${resp.statusText}
2006
+
2007
+ ${truncated}`;
2008
+ stepDescription = `${method} ${reqUrl} \u2192 ${resp.status}`;
2009
+ } catch (err) {
2010
+ const msg = err instanceof Error ? err.message : String(err);
2011
+ result = `HTTP request failed: ${msg}`;
2012
+ stepStatus = "failed";
2013
+ stepDescription = `${method} ${reqUrl} failed`;
2014
+ }
2015
+ stepAction = "http";
2016
+ break;
2017
+ }
2018
+ case "wait_for_stable": {
2019
+ const timeoutSec = Math.min(toolInput.timeout_seconds || 30, 60);
2020
+ const stableSec = Math.min(toolInput.stable_seconds || 2, 10);
2021
+ stepDescription = `Wait for page to stabilize (${stableSec}s quiet, ${timeoutSec}s max)`;
2022
+ this.emit("step", { id: stepCounter, action: "wait", description: stepDescription, status: "running", timestamp: Date.now() });
2023
+ try {
2024
+ await this.browser.evaluate(`
2025
+ window.__assrt_mutations = 0;
2026
+ window.__assrt_observer = new MutationObserver((mutations) => {
2027
+ window.__assrt_mutations += mutations.length;
2028
+ });
2029
+ window.__assrt_observer.observe(document.body, {
2030
+ childList: true, subtree: true, characterData: true
2031
+ });
2032
+ `);
2033
+ const startMs = Date.now();
2034
+ const timeoutMs = timeoutSec * 1e3;
2035
+ const stableMs = stableSec * 1e3;
2036
+ let lastMutationCount = -1;
2037
+ let stableSince = Date.now();
2038
+ while (Date.now() - startMs < timeoutMs) {
2039
+ await new Promise((r) => setTimeout(r, 500));
2040
+ const countStr = await this.browser.evaluate("window.__assrt_mutations");
2041
+ const count = parseInt(countStr, 10) || 0;
2042
+ if (count !== lastMutationCount) {
2043
+ lastMutationCount = count;
2044
+ stableSince = Date.now();
2045
+ } else if (Date.now() - stableSince >= stableMs) {
2046
+ break;
2047
+ }
2048
+ }
2049
+ await this.browser.evaluate(`
2050
+ if (window.__assrt_observer) { window.__assrt_observer.disconnect(); }
2051
+ delete window.__assrt_mutations;
2052
+ delete window.__assrt_observer;
2053
+ `);
2054
+ const elapsed = ((Date.now() - startMs) / 1e3).toFixed(1);
2055
+ const stable = Date.now() - stableSince >= stableMs;
2056
+ result = stable ? `Page stabilized after ${elapsed}s (${lastMutationCount} total mutations)` : `Timed out after ${timeoutSec}s (page still changing, ${lastMutationCount} mutations)`;
2057
+ stepDescription = stable ? `Page stable after ${elapsed}s` : `Stability timeout after ${timeoutSec}s`;
2058
+ } catch (err) {
2059
+ const msg = err instanceof Error ? err.message : String(err);
2060
+ result = `wait_for_stable failed: ${msg}`;
2061
+ stepStatus = "failed";
2062
+ stepDescription = "wait_for_stable failed";
2063
+ }
2064
+ stepAction = "wait";
2065
+ break;
2066
+ }
2067
+ default:
2068
+ result = `Unknown: ${toolCall.name}`;
2069
+ stepDescription = `Unknown: ${toolCall.name}`;
2070
+ }
2071
+ } catch (err) {
2072
+ const msg = err instanceof Error ? err.message : String(err);
2073
+ let snapshotText = "";
2074
+ try {
2075
+ snapshotText = await this.browser.snapshot();
2076
+ } catch {
2077
+ }
2078
+ result = `Error: ${msg}
2079
+
2080
+ The action "${toolCall.name}" failed. Current page accessibility tree:
2081
+ ${snapshotText.slice(0, 2e3)}
2082
+
2083
+ Please call snapshot and try a different approach.`;
2084
+ stepStatus = "failed";
2085
+ stepDescription = `${toolCall.name} failed: ${msg.slice(0, 100)}`;
2086
+ }
2087
+ let screenshotData = null;
2088
+ if (!["snapshot", "wait", "wait_for_stable", "assert", "complete_scenario", "create_temp_email", "wait_for_verification_code", "check_email_inbox", "screenshot", "evaluate", "http_request"].includes(toolCall.name)) {
2089
+ try {
2090
+ screenshotData = await this.browser.screenshot();
2091
+ if (screenshotData) {
2092
+ console.log(JSON.stringify({ event: "agent.screenshot.emit", size: screenshotData.length, ts: (/* @__PURE__ */ new Date()).toISOString() }));
2093
+ this.emit("screenshot", { base64: screenshotData });
2094
+ }
2095
+ } catch {
2096
+ }
2097
+ }
2098
+ steps.push({ id: stepCounter, action: stepAction, description: stepDescription, status: stepStatus, timestamp: Date.now() });
2099
+ this.emit("step", { id: stepCounter, action: stepAction, description: stepDescription, status: stepStatus, timestamp: Date.now() });
2100
+ if (this.provider === "gemini") {
2101
+ toolResults.push({ functionResponse: { name: toolCall.name, response: { result } } });
2102
+ } else {
2103
+ const toolResultContent = [{ type: "text", text: result }];
2104
+ if (screenshotData) {
2105
+ toolResultContent.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: screenshotData } });
2106
+ }
2107
+ toolResults.push({ type: "tool_result", tool_use_id: toolCall.id, content: toolResultContent });
2108
+ }
2109
+ this.browserBusy = false;
2110
+ }
2111
+ await this.flushDiscovery().catch(() => {
2112
+ });
2113
+ if (this.provider === "gemini") {
2114
+ messages.push({ role: "function", parts: toolResults });
2115
+ const latestScreenshot = await this.browser.screenshot();
2116
+ const latestSnapshot = await this.browser.snapshot();
2117
+ messages.push({ role: "user", parts: [
2118
+ ...latestScreenshot ? [{ inlineData: { mimeType: "image/jpeg", data: latestScreenshot } }] : [],
2119
+ { text: `Current page accessibility tree:
2120
+ ${latestSnapshot.slice(0, 3e3)}
2121
+
2122
+ Continue with the test scenario.` }
2123
+ ] });
2124
+ } else {
2125
+ messages.push({ role: "user", content: toolResults });
2126
+ }
2127
+ if (messages.length > MAX_CONVERSATION_TURNS * 2 + 2) {
2128
+ const initial = messages.slice(0, 1);
2129
+ let cutIdx = messages.length - MAX_CONVERSATION_TURNS * 2;
2130
+ if (cutIdx < 1) cutIdx = 1;
2131
+ while (cutIdx < messages.length - 2) {
2132
+ const msg = messages[cutIdx];
2133
+ const role = msg.role;
2134
+ if (role === "assistant" || role === "model") break;
2135
+ cutIdx++;
2136
+ }
2137
+ const recent = messages.slice(cutIdx);
2138
+ messages.length = 0;
2139
+ messages.push(...initial, ...recent);
2140
+ }
2141
+ }
2142
+ if (!completed) scenarioSummary = `Reached max steps (${MAX_STEPS_PER_SCENARIO})`;
2143
+ return { name: scenarioName, passed: scenarioPassed, steps, assertions, summary: scenarioSummary, duration: Date.now() - startTime };
2144
+ }
2145
+ };
2146
+
2147
+ // src/core/telemetry.ts
2148
+ import { createHash } from "crypto";
2149
+ import { hostname, platform, arch, userInfo } from "os";
2150
+ import { readFileSync as readSync, writeFileSync as writeSync } from "fs";
2151
+ import { join as joinPath } from "path";
2152
+ import { tmpdir as getTmpdir } from "os";
2153
+ var POSTHOG_KEY = "phc_mS27BrT7FC5m3BiVjDj9T4iOpVY9Oh1PB3g3bHsEiQv";
2154
+ var POSTHOG_HOST = "https://us.i.posthog.com";
2155
+ function isEnabled() {
2156
+ if (process.env.DO_NOT_TRACK === "1") return false;
2157
+ if (process.env.ASSRT_TELEMETRY === "0") return false;
2158
+ return true;
2159
+ }
2160
+ function getMachineId() {
2161
+ const raw = `${hostname()}:${userInfo().username}:assrt`;
2162
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16);
2163
+ }
2164
+ function getDomain(url) {
2165
+ try {
2166
+ return new URL(url).hostname;
2167
+ } catch {
2168
+ return "unknown";
2169
+ }
2170
+ }
2171
+ var posthogClient = null;
2172
+ async function getClient() {
2173
+ if (posthogClient) return posthogClient;
2174
+ try {
2175
+ const { PostHog } = await import("posthog-node");
2176
+ posthogClient = new PostHog(POSTHOG_KEY, {
2177
+ host: POSTHOG_HOST,
2178
+ flushAt: 1,
2179
+ flushInterval: 0,
2180
+ // Custom fetch that silently swallows TLS/network errors.
2181
+ // Returns a fake 200 so PostHog SDK doesn't log errors to stderr.
2182
+ fetch: async (url, options) => {
2183
+ try {
2184
+ return await globalThis.fetch(url, options);
2185
+ } catch {
2186
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
2187
+ }
2188
+ }
2189
+ });
2190
+ posthogClient.on("error", () => {
2191
+ });
2192
+ return posthogClient;
2193
+ } catch {
2194
+ return null;
2195
+ }
2196
+ }
2197
+ var DEDUP_FILE = joinPath(getTmpdir(), "assrt-telemetry-dedup.json");
2198
+ function shouldSend(event) {
2199
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2200
+ const machineId = getMachineId();
2201
+ const key = `${machineId}:${event}`;
2202
+ try {
2203
+ const data = JSON.parse(readSync(DEDUP_FILE, "utf-8"));
2204
+ if (data[key] === today) return false;
2205
+ data[key] = today;
2206
+ writeSync(DEDUP_FILE, JSON.stringify(data));
2207
+ return true;
2208
+ } catch {
2209
+ try {
2210
+ writeSync(DEDUP_FILE, JSON.stringify({ [key]: today }));
2211
+ } catch {
2212
+ }
2213
+ return true;
2214
+ }
2215
+ }
2216
+ async function trackEvent(event, props = {}, options) {
2217
+ if (!isEnabled()) return;
2218
+ if (options?.dedupeDaily && !shouldSend(event)) return;
2219
+ try {
2220
+ const client = await getClient();
2221
+ if (!client) return;
2222
+ const { url, ...rest } = props;
2223
+ client.capture({
2224
+ distinctId: getMachineId(),
2225
+ event,
2226
+ properties: {
2227
+ ...rest,
2228
+ domain: url ? getDomain(url) : void 0,
2229
+ version: "0.3.1",
2230
+ os: platform(),
2231
+ arch: arch(),
2232
+ node_version: process.version
2233
+ }
2234
+ });
2235
+ } catch {
2236
+ }
2237
+ }
2238
+ async function shutdownTelemetry() {
2239
+ if (posthogClient) {
2240
+ try {
2241
+ await posthogClient.shutdown();
2242
+ } catch {
2243
+ }
2244
+ }
2245
+ }
2246
+
2247
+ export {
2248
+ getCredential,
2249
+ createTestVm,
2250
+ destroyTestVm,
2251
+ isFreestyleConfigured,
2252
+ McpBrowserManager,
2253
+ TestAgent,
2254
+ trackEvent,
2255
+ shutdownTelemetry
2256
+ };