@mlola/backend-playwright 0.1.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.
@@ -0,0 +1,945 @@
1
+ /**
2
+ * Playwright browser backend (PRD §9.2, §9.3, §48).
3
+ *
4
+ * Browser mechanics only: sessions, atomic observation, typed execution,
5
+ * freshness/preflight checks, tabs, downloads, uploads, diagnostics and
6
+ * screenshots. No decisions, no model output, no policy.
7
+ */
8
+ import { mkdirSync, readFileSync } from "node:fs";
9
+ import { basename, join } from "node:path";
10
+ import { chromium, } from "playwright-core";
11
+ import { DEFAULT_BROWSER_CONFIG, RuntimeError, newSnapshotId, newTabId, } from "@mlola/browser-protocol";
12
+ import { collectSnapshot, probeDocument, validateTarget } from "./snapshot-script.js";
13
+ import { pageFn } from "./page-fn.js";
14
+ const CAPABILITIES = {
15
+ observe: true,
16
+ click: true,
17
+ type: true,
18
+ select: true,
19
+ scroll: true,
20
+ press: true,
21
+ history: true,
22
+ tabs: true,
23
+ open_tab: true,
24
+ close_tab: true,
25
+ borrow: false,
26
+ return_tab: false,
27
+ human_help: false,
28
+ download: true,
29
+ upload: true,
30
+ console: true,
31
+ network: true,
32
+ screenshot: true,
33
+ canvas: false,
34
+ dialog_handling: true,
35
+ clipboard: false,
36
+ };
37
+ const MAX_DIAGNOSTICS = 40;
38
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
39
+ export class PlaywrightBackend {
40
+ name = "playwright";
41
+ capabilities = CAPABILITIES;
42
+ #config = DEFAULT_BROWSER_CONFIG;
43
+ #browser;
44
+ #context;
45
+ #pages = [];
46
+ #activeTab;
47
+ #cdp = new Map();
48
+ #nodeIndex = new Map();
49
+ #pendingDialog;
50
+ #dialogWaiters = new Set();
51
+ #dialogDiagnostics = [];
52
+ #pendingFileChooser;
53
+ #downloads = [];
54
+ #console = [];
55
+ #failedRequests = [];
56
+ #navCounter = 0;
57
+ #tabCounter = 0;
58
+ #generation = 0;
59
+ #artifactsDir = "";
60
+ #screenshotsDir = "";
61
+ #closeDiagnostics = [];
62
+ #humanHandler;
63
+ /* ─────────────────────────────── session ──────────────────────────────── */
64
+ async startSession(input) {
65
+ this.#config = { ...DEFAULT_BROWSER_CONFIG, ...input.browser };
66
+ this.#artifactsDir = input.artifactsDir;
67
+ this.#screenshotsDir = input.screenshotsDir ?? join(input.artifactsDir, "screenshots");
68
+ mkdirSync(this.#artifactsDir, { recursive: true });
69
+ mkdirSync(this.#screenshotsDir, { recursive: true });
70
+ if (this.#config.mode === "cdp") {
71
+ const url = this.#config.cdpUrl ?? "http://127.0.0.1:9222";
72
+ this.#browser = await chromium.connectOverCDP(url);
73
+ const context = this.#browser.contexts()[0];
74
+ this.#context = context ?? (await this.#browser.newContext(this.#contextOptions()));
75
+ // Everything that already exists belongs to the user (PRD §10).
76
+ for (const page of this.#context.pages())
77
+ this.#registerPage(page, "user_owned", false);
78
+ }
79
+ else {
80
+ const launched = await this.#launch();
81
+ this.#browser = launched.browser;
82
+ this.#context = launched.context;
83
+ const existing = this.#context.pages();
84
+ for (const page of existing)
85
+ this.#registerPage(page, "agent_created", true);
86
+ }
87
+ const context = this.#context;
88
+ if (!context)
89
+ throw new RuntimeError("INTERNAL", "failed to create a browser context");
90
+ context.on("page", (page) => {
91
+ this.#registerPage(page, "agent_created", true);
92
+ });
93
+ if (this.#pages.length === 0) {
94
+ const page = await context.newPage();
95
+ this.#registerPage(page, "agent_created", true);
96
+ }
97
+ const active = this.#pages.find((p) => p.openedByRuntime) ?? this.#pages[0];
98
+ if (active) {
99
+ this.#activeTab = active.tabId;
100
+ await active.page.bringToFront().catch(() => undefined);
101
+ }
102
+ return {
103
+ sessionId: input.sessionId,
104
+ runId: input.runId,
105
+ backend: this.name,
106
+ capabilities: CAPABILITIES,
107
+ startedAt: Date.now(),
108
+ };
109
+ }
110
+ async #launch() {
111
+ const options = this.#launchOptions();
112
+ const channel = this.#config.channel ?? "chrome";
113
+ if (this.#config.userDataDir) {
114
+ try {
115
+ const context = await chromium.launchPersistentContext(this.#config.userDataDir, {
116
+ ...this.#contextOptions(),
117
+ ...options,
118
+ channel,
119
+ });
120
+ const browser = context.browser();
121
+ if (!browser)
122
+ throw new Error("persistent context has no browser handle");
123
+ return { browser, context };
124
+ }
125
+ catch (err) {
126
+ this.#closeDiagnostics.push(`persistent launch failed: ${String(err)}`);
127
+ }
128
+ }
129
+ try {
130
+ const browser = await chromium.launch({ ...options, channel });
131
+ const context = await browser.newContext(this.#contextOptions());
132
+ return { browser, context };
133
+ }
134
+ catch (err) {
135
+ this.#closeDiagnostics.push(`channel "${channel}" launch failed (${String(err)}); using bundled chromium`);
136
+ const browser = await chromium.launch(options);
137
+ const context = await browser.newContext(this.#contextOptions());
138
+ return { browser, context };
139
+ }
140
+ }
141
+ #launchOptions() {
142
+ return {
143
+ headless: this.#config.headless ?? false,
144
+ ...(this.#config.executablePath ? { executablePath: this.#config.executablePath } : {}),
145
+ ...(this.#config.slowMoMs ? { slowMo: this.#config.slowMoMs } : {}),
146
+ args: ["--no-first-run", "--no-default-browser-check"],
147
+ };
148
+ }
149
+ #contextOptions() {
150
+ return {
151
+ acceptDownloads: this.#config.acceptDownloads ?? true,
152
+ viewport: this.#config.viewport ?? { width: 1280, height: 800 },
153
+ ...(this.#config.locale ? { locale: this.#config.locale } : {}),
154
+ ...(this.#config.timezone ? { timezoneId: this.#config.timezone } : {}),
155
+ };
156
+ }
157
+ #registerPage(page, ownership, openedByRuntime) {
158
+ const existing = this.#pages.find((p) => p.page === page);
159
+ if (existing)
160
+ return existing;
161
+ const state = {
162
+ page,
163
+ tabId: newTabId(++this.#tabCounter),
164
+ ownership,
165
+ token: `p${++this.#navCounter}`,
166
+ openedByRuntime,
167
+ };
168
+ this.#pages.push(state);
169
+ page.on("framenavigated", (frame) => {
170
+ if (frame === page.mainFrame()) {
171
+ state.token = `p${++this.#navCounter}`;
172
+ this.#nodeIndex.clear();
173
+ }
174
+ });
175
+ page.on("dialog", (dialog) => {
176
+ if (dialog.type() === "alert") {
177
+ void dialog.accept().catch(() => undefined);
178
+ this.#dialogDiagnostics.push({
179
+ level: "info",
180
+ text: `alert auto-accepted: ${dialog.message().slice(0, 200)}`,
181
+ at: Date.now(),
182
+ count: 1,
183
+ });
184
+ return;
185
+ }
186
+ this.#pendingDialog = dialog;
187
+ for (const notify of this.#dialogWaiters)
188
+ notify();
189
+ });
190
+ page.on("console", (msg) => {
191
+ if (msg.type() !== "error" && msg.type() !== "warning")
192
+ return;
193
+ this.#pushDiagnostic(this.#console, {
194
+ level: msg.type() === "error" ? "error" : "warning",
195
+ text: msg.text().slice(0, 300),
196
+ at: Date.now(),
197
+ count: 1,
198
+ });
199
+ });
200
+ page.on("requestfailed", (request) => {
201
+ this.#pushDiagnostic(this.#failedRequests, {
202
+ url: request.url().slice(0, 300),
203
+ method: request.method(),
204
+ failure: request.failure()?.errorText ?? "failed",
205
+ at: Date.now(),
206
+ });
207
+ });
208
+ page.on("download", (download) => {
209
+ this.#downloads.push(download);
210
+ });
211
+ page.on("filechooser", (chooser) => {
212
+ this.#pendingFileChooser = chooser;
213
+ });
214
+ page.on("close", () => {
215
+ this.#pages = this.#pages.filter((p) => p.page !== page);
216
+ if (this.#activeTab === state.tabId)
217
+ this.#activeTab = this.#pages[0]?.tabId;
218
+ });
219
+ return state;
220
+ }
221
+ #pushDiagnostic(list, item) {
222
+ list.push(item);
223
+ if (list.length > MAX_DIAGNOSTICS)
224
+ list.shift();
225
+ }
226
+ async stopSession(_sessionId) {
227
+ const closedTabs = [];
228
+ const errors = [...this.#closeDiagnostics];
229
+ const returned = [];
230
+ // A modal dialog blocks the page; release it so tabs can actually close.
231
+ if (this.#pendingDialog) {
232
+ await this.#pendingDialog.dismiss().catch(() => undefined);
233
+ this.#pendingDialog = undefined;
234
+ }
235
+ const context = this.#context;
236
+ const browser = this.#browser;
237
+ this.#context = undefined;
238
+ this.#browser = undefined;
239
+ try {
240
+ if (context) {
241
+ // Only close tabs this runtime opened; never touch user tabs (PRD §10.2).
242
+ for (const state of [...this.#pages]) {
243
+ if (!state.openedByRuntime) {
244
+ returned.push({ tabId: state.tabId, ok: true, detail: "left open (not owned by the runtime)" });
245
+ continue;
246
+ }
247
+ try {
248
+ await state.page.close({ runBeforeUnload: false });
249
+ closedTabs.push(state.tabId);
250
+ }
251
+ catch (err) {
252
+ errors.push(`failed to close ${state.tabId}: ${String(err)}`);
253
+ }
254
+ }
255
+ await context.close().catch((err) => errors.push(`context close: ${String(err)}`));
256
+ }
257
+ if (browser)
258
+ await browser.close().catch((err) => errors.push(`browser close: ${String(err)}`));
259
+ }
260
+ finally {
261
+ this.#pages = [];
262
+ this.#activeTab = undefined;
263
+ this.#cdp.clear();
264
+ this.#nodeIndex.clear();
265
+ this.#pendingDialog = undefined;
266
+ this.#pendingFileChooser = undefined;
267
+ this.#downloads = [];
268
+ }
269
+ return {
270
+ stoppedAt: Date.now(),
271
+ closedTabs,
272
+ returnedTabs: returned,
273
+ orphanLocks: [],
274
+ errors,
275
+ };
276
+ }
277
+ setHumanHelpHandler(handler) {
278
+ this.#humanHandler = handler;
279
+ }
280
+ /* ────────────────────────────── observation ───────────────────────────── */
281
+ async observe(sessionId) {
282
+ void sessionId;
283
+ const state = this.#requireActive();
284
+ const page = state.page;
285
+ if (this.#pendingDialog) {
286
+ const dialog = this.#pendingDialog;
287
+ const message = dialog.message().slice(0, 300);
288
+ const tabs = this.#tabInfosWithoutTitles();
289
+ return {
290
+ snapshotId: newSnapshotId(),
291
+ generation: ++this.#generation,
292
+ capturedAt: Date.now(),
293
+ pageToken: state.token,
294
+ url: page.url(),
295
+ title: "",
296
+ readyState: "complete",
297
+ textSummary: "",
298
+ injectedTexts: [],
299
+ nodes: [],
300
+ viewport: { width: 0, height: 0, scrollX: 0, scrollY: 0, documentWidth: 0, documentHeight: 0 },
301
+ tabs,
302
+ activeTabId: state.tabId, blockingUi: {
303
+ kind: dialog.type() === "confirm"
304
+ ? "confirm"
305
+ : dialog.type() === "prompt"
306
+ ? "prompt"
307
+ : dialog.type() === "beforeunload"
308
+ ? "beforeunload"
309
+ : "alert",
310
+ text: message,
311
+ nodeIndexes: [],
312
+ provenance: "browser_runtime",
313
+ },
314
+ fingerprint: `dialog:${dialog.type()}:${message.length}`,
315
+ history: { canGoBack: false, canGoForward: false },
316
+ };
317
+ }
318
+ let collected;
319
+ try {
320
+ collected = await page.evaluate(pageFn(collectSnapshot), { generation: this.#generation + 1,
321
+ token: state.token,
322
+ maxNodes: 900,
323
+ maxText: 2400,
324
+ });
325
+ }
326
+ catch (err) {
327
+ throw new RuntimeError("PAGE_MUTATED", `observation failed: ${String(err)}`, { cause: err, retryable: true });
328
+ }
329
+ this.#generation += 1;
330
+ this.#nodeIndex.clear();
331
+ for (const node of collected.nodes) {
332
+ this.#nodeIndex.set(node.nodeId, { tag: node.tag, role: node.role, token: state.token });
333
+ }
334
+ const history = await this.#history(page);
335
+ const tabs = await this.#tabInfos();
336
+ const snapshot = {
337
+ snapshotId: newSnapshotId(),
338
+ generation: this.#generation,
339
+ capturedAt: Date.now(),
340
+ pageToken: state.token,
341
+ url: collected.url,
342
+ title: collected.title,
343
+ readyState: collected.readyState === "complete" ? "complete" : collected.readyState === "loading" ? "loading" : "interactive",
344
+ textSummary: collected.textSummary,
345
+ injectedTexts: collected.injectedTexts.map((t) => ({
346
+ text: t.text,
347
+ provenance: "untrusted_page_content",
348
+ suspicious: true,
349
+ })),
350
+ nodes: collected.nodes.map((n) => ({
351
+ index: n.index,
352
+ nodeId: n.nodeId,
353
+ role: n.role,
354
+ label: n.label,
355
+ labelHash: n.labelHash,
356
+ tag: n.tag,
357
+ ...(n.kind !== undefined ? { kind: n.kind } : {}),
358
+ ...(n.inputType !== undefined ? { inputType: n.inputType } : {}),
359
+ ...(n.value !== undefined ? { value: n.value } : {}),
360
+ ...(n.placeholder !== undefined ? { placeholder: n.placeholder } : {}),
361
+ ...(n.title !== undefined ? { title: n.title } : {}),
362
+ ...(n.href !== undefined ? { href: n.href } : {}),
363
+ ...(n.accept !== undefined ? { accept: n.accept } : {}),
364
+ ...(n.multiple !== undefined ? { multiple: n.multiple } : {}),
365
+ ...(n.options !== undefined ? { options: n.options } : {}),
366
+ flags: n.flags,
367
+ operations: n.operations,
368
+ ...(n.geometry !== undefined ? { geometry: n.geometry } : {}),
369
+ ...(n.documentRect !== undefined ? { documentRect: n.documentRect } : {}),
370
+ ...(n.formNodeId !== undefined ? { formNodeId: n.formNodeId } : {}),
371
+ provenance: "interface_structure",
372
+ })),
373
+ viewport: collected.viewport,
374
+ tabs,
375
+ activeTabId: state.tabId,
376
+ blockingUi: collected.blockingUi
377
+ ? {
378
+ kind: collected.blockingUi.kind,
379
+ ...(collected.blockingUi.title !== undefined ? { title: collected.blockingUi.title } : {}),
380
+ ...(collected.blockingUi.text !== undefined ? { text: collected.blockingUi.text } : {}),
381
+ nodeIndexes: collected.blockingUi.nodeIndexes,
382
+ provenance: "interface_structure",
383
+ }
384
+ : undefined,
385
+ diagnostics: { consoleErrors: [...this.#dialogDiagnostics, ...this.#console].slice(-20), failedRequests: this.#failedRequests.slice(-20) },
386
+ fingerprint: collected.fingerprint,
387
+ ...(collected.visualOnly ? { visualOnly: true } : {}),
388
+ history,
389
+ };
390
+ return snapshot;
391
+ }
392
+ async #history(page) {
393
+ try {
394
+ const cdp = await this.#cdpSession(page);
395
+ const result = (await cdp.send("Page.getNavigationHistory"));
396
+ return {
397
+ canGoBack: result.currentIndex > 0,
398
+ canGoForward: result.currentIndex < result.entries.length - 1,
399
+ };
400
+ }
401
+ catch {
402
+ // Non-Chromium (or CDP unavailable): history navigation is not offered.
403
+ return { canGoBack: false, canGoForward: false };
404
+ }
405
+ }
406
+ async #cdpSession(page) {
407
+ const existing = this.#cdp.get(page);
408
+ if (existing)
409
+ return existing;
410
+ const context = this.#context;
411
+ if (!context)
412
+ throw new Error("no browser context");
413
+ const session = await context.newCDPSession(page);
414
+ this.#cdp.set(page, session);
415
+ return session;
416
+ }
417
+ async #tabInfos() {
418
+ const infos = [];
419
+ for (const state of this.#pages) {
420
+ let title = "";
421
+ try {
422
+ title = state.page.isClosed() ? "" : await state.page.title();
423
+ }
424
+ catch {
425
+ title = "";
426
+ }
427
+ infos.push({
428
+ id: state.tabId,
429
+ title: title.slice(0, 200),
430
+ url: state.page.isClosed() ? "" : state.page.url(),
431
+ active: state.tabId === this.#activeTab,
432
+ ownership: state.ownership,
433
+ });
434
+ }
435
+ return infos;
436
+ }
437
+ /** Tab list that never round-trips to a page (safe while a dialog blocks). */
438
+ #tabInfosWithoutTitles() {
439
+ return this.#pages.map((state) => ({
440
+ id: state.tabId,
441
+ title: "",
442
+ url: state.page.isClosed() ? "" : state.page.url(),
443
+ active: state.tabId === this.#activeTab,
444
+ ownership: state.ownership,
445
+ }));
446
+ }
447
+ /* ─────────────────────────────── preflight ────────────────────────────── */
448
+ async preflight(_sessionId, action) {
449
+ const state = this.#requireActive();
450
+ if (this.#pendingDialog) {
451
+ if (action.op === "ACCEPT_DIALOG" || action.op === "DISMISS_DIALOG" || action.op === "WAIT") {
452
+ return { ok: true };
453
+ }
454
+ return {
455
+ ok: false,
456
+ code: "UNEXPECTED_DIALOG",
457
+ detail: "a blocking dialog is open; it must be resolved first",
458
+ };
459
+ }
460
+ if (action.op === "WAIT" || action.op === "BACK" || action.op === "FORWARD")
461
+ return { ok: true };
462
+ if (action.op === "ACCEPT_DIALOG" || action.op === "DISMISS_DIALOG") {
463
+ return { ok: false, code: "STALE_DECISION", detail: "no dialog is currently open" };
464
+ }
465
+ const ref = "ref" in action ? action.ref : undefined;
466
+ if (!ref)
467
+ return { ok: true };
468
+ const evidence = this.#nodeIndex.get(ref.nodeId);
469
+ if (!evidence) {
470
+ return {
471
+ ok: false,
472
+ code: "STALE_DECISION",
473
+ detail: "the target was not part of the most recent observation",
474
+ evidence: { nodeId: ref.nodeId },
475
+ };
476
+ }
477
+ const needsEditable = action.op === "TYPE_TEXT";
478
+ const requireHitTarget = action.op === "CLICK" || action.op === "DOWNLOAD" || action.op === "OPEN_TAB";
479
+ const result = await state.page.evaluate(pageFn(validateTarget), {
480
+ nodeId: ref.nodeId,
481
+ pageToken: state.token,
482
+ expectedToken: ref.pageToken,
483
+ tag: evidence.tag,
484
+ role: evidence.role,
485
+ operation: action.op,
486
+ ...(action.op === "SELECT" ? { optionValue: action.option.value, optionIndex: action.option.index } : {}),
487
+ ...(needsEditable ? { editableOnly: true } : {}),
488
+ ...(requireHitTarget ? { requireHitTarget: true } : {}),
489
+ });
490
+ if (!result.ok) {
491
+ return {
492
+ ok: false,
493
+ code: (result.code ?? "TARGET_INVALID"),
494
+ detail: result.detail,
495
+ ...(result.evidence ? { evidence: result.evidence } : {}),
496
+ };
497
+ }
498
+ return { ok: true };
499
+ }
500
+ /* ─────────────────────────────── execution ────────────────────────────── */
501
+ async execute(_sessionId, action) {
502
+ const state = this.#requireActive();
503
+ const page = state.page;
504
+ const started = Date.now();
505
+ const before = await this.#probe(page);
506
+ const urlBefore = page.url();
507
+ const tokenBefore = state.token;
508
+ const tabsBefore = this.#pages.length;
509
+ const downloadsBefore = this.#downloads.length;
510
+ const timeout = this.#config.actionTimeoutMs ?? 10_000;
511
+ let effect = "none";
512
+ let detail;
513
+ let newTabId;
514
+ let dialog;
515
+ const nodeHandle = async (nodeId) => {
516
+ const resolver = pageFn((id) => {
517
+ const w = window;
518
+ const bridge = w.__mlola_bridge_v1;
519
+ if (!bridge)
520
+ return null;
521
+ const ref = bridge.byId.get(id);
522
+ const el = ref && typeof ref.deref === "function"
523
+ ? ref.deref()
524
+ : ref;
525
+ return el ?? null;
526
+ });
527
+ const handle = await page.evaluateHandle(resolver, nodeId);
528
+ const element = handle.asElement();
529
+ if (!element)
530
+ throw new RuntimeError("DETACHED_TARGET", "the target element is gone");
531
+ return element;
532
+ };
533
+ /**
534
+ * Runs a browser action and returns early when a native dialog opens: a
535
+ * modal dialog blocks the renderer, so the action's promise may not settle
536
+ * until the dialog is resolved (PRD §37).
537
+ */
538
+ const act = async (operation) => {
539
+ if (this.#pendingDialog)
540
+ return "dialog";
541
+ const promise = operation();
542
+ let notify;
543
+ const signal = new Promise((resolve) => {
544
+ notify = () => resolve("dialog");
545
+ });
546
+ this.#dialogWaiters.add(notify);
547
+ try {
548
+ const outcome = await Promise.race([promise.then((value) => ({ kind: "done", value })), signal]);
549
+ if (outcome === "dialog") {
550
+ void promise.catch(() => undefined);
551
+ return "dialog";
552
+ }
553
+ return outcome.value;
554
+ }
555
+ finally {
556
+ this.#dialogWaiters.delete(notify);
557
+ }
558
+ };
559
+ switch (action.op) {
560
+ case "CLICK": {
561
+ const element = await nodeHandle(action.ref.nodeId);
562
+ const clicked = await act(() => element.click({ timeout, clickCount: action.clickCount ?? 1 }));
563
+ detail = clicked === "dialog" ? "click opened a dialog" : "clicked";
564
+ break;
565
+ }
566
+ case "TYPE_TEXT": {
567
+ const element = await nodeHandle(action.ref.nodeId);
568
+ if (action.mode === "fill") {
569
+ await element.fill(action.text, { timeout });
570
+ }
571
+ else {
572
+ await element.click({ timeout });
573
+ await page.keyboard.type(action.text, { delay: 12 });
574
+ }
575
+ if (action.submitAfter) {
576
+ const pressed = await act(() => element.press("Enter", { timeout }));
577
+ if (pressed === "dialog")
578
+ detail = "submit opened a dialog";
579
+ }
580
+ if (!this.#pendingDialog)
581
+ await this.#settle(page, true);
582
+ detail ??= `typed ${action.text.length} char(s)`;
583
+ break;
584
+ }
585
+ case "SELECT": {
586
+ const element = await nodeHandle(action.ref.nodeId);
587
+ const option = action.option.value.length > 0 ? { value: action.option.value } : { label: action.option.label };
588
+ await element.selectOption(option, { timeout });
589
+ detail = `selected ${action.option.label}`;
590
+ break;
591
+ }
592
+ case "SCROLL_UP":
593
+ case "SCROLL_DOWN": {
594
+ const viewport = page.viewportSize() ?? { width: 1280, height: 800 };
595
+ const amount = action.amount === "page" ? 0.9 : action.amount === "half" ? 0.5 : 0.25;
596
+ const delta = Math.round(viewport.height * amount) * (action.op === "SCROLL_DOWN" ? 1 : -1);
597
+ await page.mouse.move(Math.round(viewport.width / 2), Math.round(viewport.height / 2));
598
+ await page.mouse.wheel(0, delta);
599
+ effect = "scrolled";
600
+ detail = `scrolled ${delta}px`;
601
+ break;
602
+ }
603
+ case "WAIT":
604
+ await sleep(action.ms);
605
+ effect = "none";
606
+ detail = `waited ${action.ms}ms`;
607
+ break;
608
+ case "BACK": {
609
+ const response = await page.goBack({ waitUntil: "domcontentloaded", timeout }).catch(() => null);
610
+ if (response === null) {
611
+ detail = "no previous entry";
612
+ effect = "none";
613
+ }
614
+ else {
615
+ effect = "navigated";
616
+ detail = `back to ${page.url()}`;
617
+ }
618
+ break;
619
+ }
620
+ case "FORWARD": {
621
+ const response = await page.goForward({ waitUntil: "domcontentloaded", timeout }).catch(() => null);
622
+ if (response === null) {
623
+ detail = "no forward entry";
624
+ effect = "none";
625
+ }
626
+ else {
627
+ effect = "navigated";
628
+ detail = `forward to ${page.url()}`;
629
+ }
630
+ break;
631
+ }
632
+ case "OPEN_TAB": {
633
+ const beforeCount = this.#pages.length;
634
+ if (action.sourceRef) {
635
+ const element = await nodeHandle(action.sourceRef.nodeId);
636
+ await element.click({ timeout }).catch(() => undefined);
637
+ await this.#waitFor(() => this.#pages.length > beforeCount, 2500);
638
+ }
639
+ if (this.#pages.length === beforeCount) {
640
+ const context = this.#context;
641
+ if (!context)
642
+ throw new RuntimeError("INTERNAL", "no browser context");
643
+ const created = await context.newPage();
644
+ this.#registerPage(created, "agent_created", true);
645
+ await created.goto(action.url, { waitUntil: "domcontentloaded", timeout });
646
+ }
647
+ const created = this.#pages[this.#pages.length - 1];
648
+ if (created) {
649
+ newTabId = created.tabId;
650
+ this.#activeTab = created.tabId;
651
+ await created.page.bringToFront().catch(() => undefined);
652
+ }
653
+ effect = "new_tab";
654
+ detail = `opened ${action.url}`;
655
+ break;
656
+ }
657
+ case "SWITCH_TAB": {
658
+ const target = this.#pages.find((p) => p.tabId === action.tabId);
659
+ if (!target)
660
+ throw new RuntimeError("TARGET_NOT_FOUND", `unknown tab ${action.tabId}`);
661
+ this.#activeTab = target.tabId;
662
+ await target.page.bringToFront().catch(() => undefined);
663
+ effect = "tab_switched";
664
+ detail = `switched to ${target.tabId}`;
665
+ break;
666
+ }
667
+ case "CLOSE_TAB": {
668
+ const target = this.#pages.find((p) => p.tabId === action.tabId);
669
+ if (!target)
670
+ throw new RuntimeError("TARGET_NOT_FOUND", `unknown tab ${action.tabId}`);
671
+ await target.page.close({ runBeforeUnload: false });
672
+ effect = "tab_closed";
673
+ detail = `closed ${action.tabId}`;
674
+ break;
675
+ }
676
+ case "DOWNLOAD": {
677
+ const element = await nodeHandle(action.ref.nodeId);
678
+ await act(() => element.click({ timeout }));
679
+ await this.#waitFor(() => this.#downloads.length > downloadsBefore, 3000);
680
+ detail = "download triggered";
681
+ break;
682
+ }
683
+ case "UPLOAD": {
684
+ const element = await nodeHandle(action.ref.nodeId);
685
+ const filePath = this.#uploadPath(action.artifactId);
686
+ const mechanism = action.mechanism;
687
+ if (mechanism === "file_input") {
688
+ await element.setInputFiles(filePath, { timeout });
689
+ }
690
+ else if (mechanism === "file_chooser") {
691
+ this.#pendingFileChooser = undefined;
692
+ await element.click({ timeout });
693
+ const chooser = await this.#waitForValue(() => this.#pendingFileChooser, 2500);
694
+ if (!chooser)
695
+ throw new RuntimeError("UPLOAD_FAILED", "no file chooser appeared");
696
+ await chooser.setFiles(filePath);
697
+ this.#pendingFileChooser = undefined;
698
+ }
699
+ else {
700
+ await this.#dropFiles(element, filePath);
701
+ }
702
+ effect = "upload_dispatched";
703
+ detail = `uploaded ${basename(filePath)} via ${mechanism}`;
704
+ break;
705
+ }
706
+ case "PRESS": {
707
+ if (action.ref) {
708
+ const element = await nodeHandle(action.ref.nodeId);
709
+ await act(() => element.press(action.key, { timeout }));
710
+ }
711
+ else {
712
+ await act(() => page.keyboard.press(action.key));
713
+ }
714
+ detail = `pressed ${action.key}`;
715
+ break;
716
+ }
717
+ case "ACCEPT_DIALOG":
718
+ case "DISMISS_DIALOG": {
719
+ const dialog = this.#pendingDialog;
720
+ if (!dialog)
721
+ throw new RuntimeError("STALE_DECISION", "no dialog is open");
722
+ if (action.op === "ACCEPT_DIALOG")
723
+ await dialog.accept();
724
+ else
725
+ await dialog.dismiss();
726
+ this.#pendingDialog = undefined;
727
+ effect = "dialog_closed";
728
+ detail = `dialog ${action.op === "ACCEPT_DIALOG" ? "accepted" : "dismissed"}`;
729
+ break;
730
+ }
731
+ default:
732
+ throw new RuntimeError("TARGET_INVALID", `playwright backend cannot execute ${action.op}`);
733
+ }
734
+ // Bounded settle + effect probe (PRD §26). While a native dialog is open the
735
+ // renderer is blocked, so page round-trips must be skipped.
736
+ const dialogOpen = this.#pendingDialog !== undefined;
737
+ if (!dialogOpen)
738
+ await this.#settle(page, action.op === "TYPE_TEXT");
739
+ const after = dialogOpen ? undefined : await this.#probe(page).catch(() => undefined);
740
+ const tokenAfter = state.token;
741
+ const urlAfter = page.url();
742
+ const tabCountAfter = this.#pages.length;
743
+ if (tokenBefore !== tokenAfter || urlBefore !== urlAfter) {
744
+ if (effect === "none")
745
+ effect = "navigated";
746
+ }
747
+ else if (after && before && after.mutationCount > before.mutationCount && effect === "none") {
748
+ effect = "dom_mutated";
749
+ }
750
+ if (effect === "scrolled" && after && before && after.scrollY === before.scrollY) {
751
+ effect = "none";
752
+ }
753
+ if (tabCountAfter < tabsBefore && effect === "none")
754
+ effect = "tab_closed";
755
+ if (tabCountAfter > tabsBefore) {
756
+ if (effect === "none" || effect === "dom_mutated")
757
+ effect = "new_tab";
758
+ const newest = this.#pages[this.#pages.length - 1];
759
+ if (newest && newest.tabId !== state.tabId)
760
+ newTabId = newest.tabId;
761
+ }
762
+ if (this.#pendingDialog && effect === "none") {
763
+ effect = "dialog_opened";
764
+ dialog = { kind: this.#pendingDialog.type(), text: this.#pendingDialog.message().slice(0, 200) };
765
+ }
766
+ // Downloads started by this action: wait for completion and hand the file over.
767
+ let downloadedFile;
768
+ if (this.#downloads.length > downloadsBefore) {
769
+ const download = this.#downloads[this.#downloads.length - 1];
770
+ if (download) {
771
+ downloadedFile = await this.#collectDownload(download);
772
+ if (effect === "none" || effect === "dom_mutated")
773
+ effect = "download_completed";
774
+ }
775
+ }
776
+ let unknownEffect = false;
777
+ if (action.op === "DOWNLOAD" && !downloadedFile) {
778
+ const inconclusive = effect === "none" || effect === "dom_mutated" || effect === "scrolled";
779
+ unknownEffect = inconclusive;
780
+ detail = `${detail ?? "download"} (no file captured)`;
781
+ }
782
+ if (action.op === "UPLOAD" && effect !== "upload_dispatched")
783
+ unknownEffect = true;
784
+ if (action.op === "CLICK" && effect === "none") {
785
+ // Clicks can legitimately have no visible effect; do not claim failure.
786
+ detail = `${detail ?? "clicked"} (no observable effect)`;
787
+ }
788
+ return {
789
+ ok: true,
790
+ effect,
791
+ ...(detail !== undefined ? { detail } : {}),
792
+ latencyMs: Date.now() - started,
793
+ urlBefore,
794
+ urlAfter,
795
+ pageTokenBefore: tokenBefore,
796
+ pageTokenAfter: tokenAfter,
797
+ ...(unknownEffect ? { unknownEffect: true } : {}),
798
+ ...(downloadedFile ? { downloadedFile } : {}),
799
+ ...(newTabId ? { newTabId } : {}),
800
+ ...(dialog ? { dialog } : {}),
801
+ };
802
+ }
803
+ async navigate(_sessionId, url) {
804
+ const state = this.#requireActive();
805
+ const started = Date.now();
806
+ const urlBefore = state.page.url();
807
+ const tokenBefore = state.token;
808
+ try {
809
+ await state.page.goto(url, { waitUntil: "domcontentloaded", timeout: this.#config.actionTimeoutMs ?? 15_000 });
810
+ }
811
+ catch (err) {
812
+ const message = String(err);
813
+ if (/Timeout/i.test(message)) {
814
+ throw new RuntimeError("NAVIGATION_TIMEOUT", `navigation to ${url} timed out`, { cause: err });
815
+ }
816
+ throw new RuntimeError("NETWORK_FAILURE", `navigation to ${url} failed: ${message}`, { cause: err });
817
+ }
818
+ await this.#settle(state.page, false);
819
+ return {
820
+ ok: true,
821
+ effect: "navigated",
822
+ detail: `navigated to ${state.page.url()}`,
823
+ latencyMs: Date.now() - started,
824
+ urlBefore,
825
+ urlAfter: state.page.url(),
826
+ pageTokenBefore: tokenBefore,
827
+ pageTokenAfter: state.token,
828
+ };
829
+ }
830
+ async listTabs(_sessionId) {
831
+ return this.#tabInfos();
832
+ }
833
+ async switchTab(_sessionId, tabId) {
834
+ const target = this.#pages.find((p) => p.tabId === tabId);
835
+ if (!target)
836
+ throw new RuntimeError("TARGET_NOT_FOUND", `unknown tab ${tabId}`);
837
+ this.#activeTab = tabId;
838
+ await target.page.bringToFront().catch(() => undefined);
839
+ }
840
+ async getDiagnostics(_sessionId) {
841
+ return {
842
+ consoleErrors: [...this.#dialogDiagnostics, ...this.#console].slice(-20),
843
+ failedRequests: this.#failedRequests.slice(-20),
844
+ };
845
+ }
846
+ async screenshot(_sessionId, reason) {
847
+ const state = this.#requireActive();
848
+ const safeReason = reason.replace(/[^a-z0-9-]+/gi, "-").slice(0, 40);
849
+ const path = join(this.#screenshotsDir, `${Date.now()}-${safeReason}.png`);
850
+ await state.page.screenshot({ path, fullPage: false });
851
+ const size = state.page.viewportSize() ?? { width: 0, height: 0 };
852
+ return { path, width: size.width, height: size.height };
853
+ }
854
+ /* ──────────────────────────────── helpers ─────────────────────────────── */
855
+ #requireActive() {
856
+ const state = this.#pages.find((p) => p.tabId === this.#activeTab) ?? this.#pages[0];
857
+ if (!state)
858
+ throw new RuntimeError("INTERNAL", "no active browser tab");
859
+ return state;
860
+ }
861
+ async #probe(page) {
862
+ return page.evaluate(pageFn(probeDocument)).catch(() => undefined);
863
+ }
864
+ async #settle(page, waitForOptions) {
865
+ const settleMs = this.#config.settleMs ?? 250;
866
+ if (waitForOptions) {
867
+ // Autocomplete/combobox: wait for a visible option list, bounded (PRD §26).
868
+ await page
869
+ .waitForSelector('[role="listbox"], [role="option"], .autocomplete-items li, datalist option', {
870
+ timeout: 900,
871
+ state: "attached",
872
+ })
873
+ .catch(() => undefined);
874
+ }
875
+ await sleep(settleMs);
876
+ await page.waitForLoadState("domcontentloaded", { timeout: 3000 }).catch(() => undefined);
877
+ }
878
+ async #waitFor(predicate, timeoutMs) {
879
+ const deadline = Date.now() + timeoutMs;
880
+ while (Date.now() < deadline) {
881
+ if (predicate())
882
+ return true;
883
+ await sleep(60);
884
+ }
885
+ return predicate();
886
+ }
887
+ async #waitForValue(get, timeoutMs) {
888
+ const deadline = Date.now() + timeoutMs;
889
+ while (Date.now() < deadline) {
890
+ const value = get();
891
+ if (value !== undefined)
892
+ return value;
893
+ await sleep(60);
894
+ }
895
+ return get();
896
+ }
897
+ #uploadPath(artifactId) {
898
+ const registryPath = join(this.#artifactsDir, "registry.json");
899
+ try {
900
+ const parsed = JSON.parse(readFileSync(registryPath, "utf8"));
901
+ const record = parsed.records?.find((r) => r.id === artifactId);
902
+ if (record)
903
+ return record.localPath;
904
+ }
905
+ catch {
906
+ /* fall through */
907
+ }
908
+ throw new RuntimeError("UPLOAD_FAILED", `artifact ${artifactId} is not registered on disk`);
909
+ }
910
+ async #dropFiles(element, filePath) {
911
+ const bytes = readFileSync(filePath);
912
+ const b64 = bytes.toString("base64");
913
+ const name = basename(filePath);
914
+ await element.evaluate(pageFn((el, arg) => {
915
+ const bin = atob(arg.b64);
916
+ const arr = new Uint8Array(bin.length);
917
+ for (let i = 0; i < bin.length; i += 1)
918
+ arr[i] = bin.charCodeAt(i);
919
+ const file = new File([arr], arg.name, { type: "application/octet-stream" });
920
+ const dt = new DataTransfer();
921
+ dt.items.add(file);
922
+ for (const type of ["dragenter", "dragover", "drop"]) {
923
+ el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
924
+ }
925
+ }), { b64, name });
926
+ }
927
+ async #collectDownload(download) {
928
+ const suggestedName = download.suggestedFilename();
929
+ const url = download.url();
930
+ try {
931
+ const failure = await download.failure();
932
+ if (failure)
933
+ return { path: "", suggestedName, url, completed: false, failure };
934
+ const target = join(this.#artifactsDir, "downloads", suggestedName);
935
+ mkdirSync(join(this.#artifactsDir, "downloads"), { recursive: true });
936
+ await download.saveAs(target);
937
+ return { path: target, suggestedName, url, completed: true };
938
+ }
939
+ catch (err) {
940
+ return { path: "", suggestedName, url, completed: false, failure: String(err) };
941
+ }
942
+ }
943
+ }
944
+ export const createPlaywrightBackend = () => new PlaywrightBackend();
945
+ //# sourceMappingURL=backend.js.map