@tea-agent/loop-agent 0.44.0-next.12 → 0.44.0-next.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -2
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init.js +1 -0
- package/dist/executors/dag-pi-executor.js +11 -6
- package/dist/executors/pi-writer-tool-policy.js +60 -4
- package/dist/executors/playwright-cli-launcher.js +31 -45
- package/dist/executors/shell-write-guard.js +24 -67
- package/dist/governance/exec-plans.js +10 -1
- package/dist/task/config-types.js +4 -2
- package/dist/task/source-prepare/build-draft.js +1 -0
- package/dist/task/source-prepare/path-policy.js +17 -7
- package/dist/worker/console/chat/browser-automation.js +343 -38
- package/dist/worker/console/chat/browser-content.js +74 -0
- package/dist/worker/console/chat/browser-diagnostics.js +250 -0
- package/dist/worker/console/chat/browser-evaluation.js +58 -0
- package/dist/worker/console/chat/browser-operation-queue.js +19 -0
- package/dist/worker/console/chat/browser-pick.js +113 -0
- package/dist/worker/console/chat/browser-routes.js +33 -5
- package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
- package/dist/worker/console/chat/browser-storage.js +89 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
- package/dist/worker/console/chat/pi-runtime.js +1 -1
- package/dist/worker/console/chat/repo-browser.js +80 -2
- package/dist/worker/console/chat/repo-preview-kind.js +155 -0
- package/dist/worker/console/chat/session-mode.js +1 -1
- package/dist/worker/console/chat/session-store.js +4 -2
- package/dist/worker/console/chat/tools.js +12 -0
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-BA7qCL_r.js} +1 -1
- package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-Df09ajwL.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CpF9sw5R.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-BtPys8ja.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DZPnTWqT.js} +1 -1
- package/dist/worker/console/static/assets/channel-DiTNLZUm.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-BD9D4h1p.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-Svn8yBmn.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-B8EbQj-S.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-tENZpcpF.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-DBx71Lco.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-1v_QamMz.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-BAMCNNBG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BvuEury-.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-YqFuHp8s.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-ouI5T7Mr.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-Bri0_Izk.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-e8Vhhi4X.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-tSIXGQiJ.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-Rl-sF5pt.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-Db6ddPz8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Jwd_Xw5S.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-CeC_8InC.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-LKPRaNq8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-DlVQytMW.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-DnfJZwO-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-3axVSXca.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-xe04-_L-.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-hpmRINiD.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js} +1 -1
- package/dist/worker/console/static/assets/index-C_8_gWal.css +1 -0
- package/dist/worker/console/static/assets/{index-CTMZsDmh.js → index-DybcVXCu.js} +102 -100
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-BF6ksWTX.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-DXisdeD7.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BWu65wmu.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-hfNhHqAI.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-D6HTfQcp.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-Iv91R1WP.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CfPChwjR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-B7TcoqHz.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-ouyPKMtQ.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-B9tuyLbh.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-Ck4q9nJs.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-wXLX5Dnp.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-D2XWw6_m.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-DzxKiug4.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-DWUqK5Us.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-D5Pa4O5g.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-VH7cRnsJ.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-C4HM5NoZ.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-Bu-4hOlA.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-CasFEvvl.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M-D-gpwPSA.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
- package/dist/worker/console/workspace-context.js +1 -0
- package/dist/worker/materialize/harness-task-lineage.js +4 -2
- package/dist/worker/observe/static/console-theme.css +33 -29
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
- package/dist/workflows/dag/backend-test-layout.js +27 -7
- package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
- package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
- package/dist/workflows/dag/frontend-test-layout.js +41 -12
- package/dist/workflows/dag/hybrid/sources.js +21 -2
- package/dist/workflows/dag/hybrid/templates/backend-test.js +65 -50
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +58 -37
- package/dist/workflows/dag/test-artifact-namespace.js +73 -0
- package/dist/workflows/dag/validate.js +22 -3
- package/docs/templates/README.md +4 -4
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
- package/docs/templates/backend-test-dag.json +708 -708
- package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
- package/docs/templates/frontend-test-dag.json +46 -37
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/package.json +7 -2
- package/skills/fe-test-ui-scout/SKILL.md +4 -4
- package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +4 -4
- package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
|
@@ -5,33 +5,53 @@
|
|
|
5
5
|
* isolated from the user's normal browser without contaminating the repo.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { chromium, } from "playwright-core";
|
|
12
|
+
import { pickBrowserElement } from "./browser-pick.js";
|
|
13
|
+
import { browserHtmlToMarkdown, captureBrowserHtml } from "./browser-content.js";
|
|
14
|
+
import { exportBrowserStorage, mergeBrowserStorage, readBrowserStorage } from "./browser-storage.js";
|
|
15
|
+
import { evaluateBrowserExpression, waitForBrowserOperation } from "./browser-evaluation.js";
|
|
16
|
+
import { BrowserOperationQueue } from "./browser-operation-queue.js";
|
|
11
17
|
import { normalizeBrowserUrl } from "./browser-policy.js";
|
|
18
|
+
import { BrowserDiagnostics } from "./browser-diagnostics.js";
|
|
12
19
|
export const BROWSER_VIEWPORT_WIDTH = 1280;
|
|
13
20
|
export const BROWSER_VIEWPORT_HEIGHT = 900;
|
|
14
21
|
const SEARCH_INPUT_SELECTORS = [
|
|
15
22
|
"input[name='wd']",
|
|
16
23
|
"input[name='q']",
|
|
17
24
|
"input[name='query']",
|
|
25
|
+
"input[name='search']",
|
|
18
26
|
"input[type='search']",
|
|
27
|
+
"[role='searchbox']",
|
|
28
|
+
"textarea[name='wd']",
|
|
29
|
+
"textarea[name='q']",
|
|
30
|
+
"textarea[name='query']",
|
|
31
|
+
"textarea[type='search']",
|
|
19
32
|
"input[type='text']",
|
|
33
|
+
"textarea",
|
|
20
34
|
];
|
|
21
35
|
const SEARCH_SUBMIT_SELECTORS = [
|
|
22
36
|
"button[type='submit']",
|
|
23
37
|
"input[type='submit']",
|
|
24
|
-
"button:not([type])",
|
|
25
38
|
];
|
|
26
|
-
const DEFAULT_SEARCH_ENGINE_URL = "https://www.
|
|
39
|
+
export const DEFAULT_SEARCH_ENGINE_URL = "https://www.bing.com/";
|
|
27
40
|
const SEARCH_TYPING_DELAY_MS = 45;
|
|
28
41
|
const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
|
|
42
|
+
const NAVIGATION_SETTLE_WINDOW_MS = 400;
|
|
43
|
+
const NAVIGATION_SETTLE_CAP_MS = 2_000;
|
|
44
|
+
const SEARCH_RESULT_TIMEOUT_MS = 15_000;
|
|
29
45
|
const MAX_SNAPSHOT_CHARS = 24_000;
|
|
30
46
|
const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
31
47
|
export class BrowserAutomationService {
|
|
32
48
|
options;
|
|
49
|
+
operations = new BrowserOperationQueue();
|
|
50
|
+
snapshots = new WeakMap();
|
|
51
|
+
recovering = new WeakSet();
|
|
33
52
|
browserPromise;
|
|
34
53
|
activePage;
|
|
54
|
+
diagnostics = new BrowserDiagnostics();
|
|
35
55
|
tabs = new Map();
|
|
36
56
|
nextTabId = 1;
|
|
37
57
|
disposed = false;
|
|
@@ -40,6 +60,14 @@ export class BrowserAutomationService {
|
|
|
40
60
|
constructor(options) {
|
|
41
61
|
this.options = options;
|
|
42
62
|
}
|
|
63
|
+
/** Runtime entrypoint: tools and HTTP must enter once, binding the target before queuing. */
|
|
64
|
+
run(input, operation) {
|
|
65
|
+
const page = input.tabId === undefined ? this.activePage : this.tabs.get(input.tabId);
|
|
66
|
+
if (input.tabId !== undefined && (!page || page.isClosed())) {
|
|
67
|
+
return Promise.reject(new BrowserAutomationError("TAB_NOT_FOUND", `tab ${input.tabId} does not exist`));
|
|
68
|
+
}
|
|
69
|
+
return this.operations.run({ page }, input.signal, operation);
|
|
70
|
+
}
|
|
43
71
|
async navigate(rawUrl) {
|
|
44
72
|
const normalized = normalizeBrowserUrl(rawUrl);
|
|
45
73
|
if (normalized.kind !== "ok") {
|
|
@@ -54,6 +82,7 @@ export class BrowserAutomationService {
|
|
|
54
82
|
try {
|
|
55
83
|
const page = await this.page();
|
|
56
84
|
await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
85
|
+
await settleNavigation(page);
|
|
57
86
|
const snapshot = await this.snapshot({ screenshot: false });
|
|
58
87
|
return { ok: true, page: snapshot };
|
|
59
88
|
}
|
|
@@ -64,23 +93,31 @@ export class BrowserAutomationService {
|
|
|
64
93
|
async reload() {
|
|
65
94
|
const page = await this.page();
|
|
66
95
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
96
|
+
await settleNavigation(page);
|
|
67
97
|
return this.snapshot({ screenshot: false });
|
|
68
98
|
}
|
|
69
99
|
async goBack() {
|
|
70
100
|
const page = await this.page();
|
|
71
101
|
await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
102
|
+
await settleNavigation(page);
|
|
72
103
|
return this.snapshot({ screenshot: false });
|
|
73
104
|
}
|
|
74
105
|
async goForward() {
|
|
75
106
|
const page = await this.page();
|
|
76
107
|
await page.goForward({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
108
|
+
await settleNavigation(page);
|
|
77
109
|
return this.snapshot({ screenshot: false });
|
|
78
110
|
}
|
|
79
111
|
async snapshot(options) {
|
|
80
|
-
const page = await this.page();
|
|
112
|
+
const page = options?.tabId === undefined ? await this.page() : this.tabs.get(options.tabId);
|
|
113
|
+
if (!page || page.isClosed())
|
|
114
|
+
throw new BrowserAutomationError("TAB_NOT_FOUND", "requested tab is unavailable");
|
|
115
|
+
if (this.recovering.has(page))
|
|
116
|
+
throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
|
|
81
117
|
const dialog = this.dialogFor(page);
|
|
82
118
|
if (dialog) {
|
|
83
119
|
return {
|
|
120
|
+
tabId: this.trackPage(page),
|
|
84
121
|
url: page.url(),
|
|
85
122
|
title: "浏览器弹窗",
|
|
86
123
|
tabs: await this.tabSummaries(),
|
|
@@ -88,7 +125,7 @@ export class BrowserAutomationService {
|
|
|
88
125
|
ariaSnapshot: "",
|
|
89
126
|
...(options?.screenshot === false
|
|
90
127
|
? {}
|
|
91
|
-
:
|
|
128
|
+
: await this.captureScreenshot(page, options?.fullPage)),
|
|
92
129
|
};
|
|
93
130
|
}
|
|
94
131
|
const [title, aria] = await Promise.all([
|
|
@@ -97,15 +134,19 @@ export class BrowserAutomationService {
|
|
|
97
134
|
]);
|
|
98
135
|
const download = await this.nextDownloadResult();
|
|
99
136
|
const screenshot = options?.screenshot === false
|
|
100
|
-
?
|
|
101
|
-
: await this.captureScreenshot(page);
|
|
137
|
+
? {}
|
|
138
|
+
: await this.captureScreenshot(page, options?.fullPage);
|
|
139
|
+
const snapshotId = randomUUID();
|
|
140
|
+
this.snapshots.set(page, snapshotId);
|
|
102
141
|
return {
|
|
142
|
+
tabId: this.trackPage(page),
|
|
143
|
+
snapshotId,
|
|
103
144
|
url: page.url(),
|
|
104
145
|
title,
|
|
105
146
|
tabs: await this.tabSummaries(),
|
|
106
147
|
dialog,
|
|
107
148
|
ariaSnapshot: truncateText(aria, MAX_SNAPSHOT_CHARS),
|
|
108
|
-
...
|
|
149
|
+
...screenshot,
|
|
109
150
|
...(download ? { download } : {}),
|
|
110
151
|
};
|
|
111
152
|
}
|
|
@@ -116,11 +157,15 @@ export class BrowserAutomationService {
|
|
|
116
157
|
async state() {
|
|
117
158
|
if (!this.browserPromise && !this.disposed)
|
|
118
159
|
return emptyBrowserState();
|
|
119
|
-
|
|
160
|
+
// Polling never starts a context, changes selection, or waits on a quarantined page.
|
|
161
|
+
const page = this.operations.current?.target.page ?? this.activePage;
|
|
162
|
+
if (!page || page.isClosed())
|
|
163
|
+
return { ...emptyBrowserState(), running: !!this.browserPromise, tabs: await this.tabSummaries() };
|
|
120
164
|
const state = {
|
|
121
165
|
running: true,
|
|
166
|
+
tabId: this.trackPage(page),
|
|
122
167
|
url: page.url(),
|
|
123
|
-
title: await page.title().catch(() => ""),
|
|
168
|
+
title: this.recovering.has(page) ? "Page busy — script may still be running" : await page.title().catch(() => ""),
|
|
124
169
|
tabs: await this.tabSummaries(),
|
|
125
170
|
dialog: this.dialogFor(page),
|
|
126
171
|
ariaSnapshot: "",
|
|
@@ -139,6 +184,7 @@ export class BrowserAutomationService {
|
|
|
139
184
|
const page = await this.page();
|
|
140
185
|
await page.bringToFront();
|
|
141
186
|
await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
187
|
+
await settleNavigation(page);
|
|
142
188
|
const input = await findSearchInput(page);
|
|
143
189
|
if (!input) {
|
|
144
190
|
throw new BrowserAutomationError("SEARCH_INPUT_NOT_FOUND", "the search page did not expose a recognizable search input");
|
|
@@ -148,14 +194,22 @@ export class BrowserAutomationService {
|
|
|
148
194
|
delay: SEARCH_TYPING_DELAY_MS,
|
|
149
195
|
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
|
150
196
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
197
|
+
// Search forms may navigate (URL change) or submit client-side (no URL
|
|
198
|
+
// change). Wait for whichever settles first, but never return while a
|
|
199
|
+
// navigation is still pending: url-commit and network-idle both race here.
|
|
200
|
+
const beforeSubmit = page.url();
|
|
201
|
+
await submitSearchForm(input);
|
|
202
|
+
await Promise.race([
|
|
203
|
+
page.waitForURL((url) => url.toString() !== beforeSubmit, { timeout: SEARCH_RESULT_TIMEOUT_MS, waitUntil: "domcontentloaded" }).catch(() => undefined),
|
|
204
|
+
page.waitForLoadState("networkidle", { timeout: SEARCH_RESULT_TIMEOUT_MS }).catch(() => undefined),
|
|
154
205
|
]);
|
|
206
|
+
await settleNavigation(page);
|
|
155
207
|
return this.snapshot({ screenshot: false });
|
|
156
208
|
}
|
|
157
209
|
async act(request) {
|
|
210
|
+
validateBrowserInput(request);
|
|
158
211
|
const page = await this.page();
|
|
212
|
+
this.checkSnapshot(page, request.snapshotId);
|
|
159
213
|
const ref = request.ref.trim();
|
|
160
214
|
if (!/^(f\d+)?e\d+$/.test(ref)) {
|
|
161
215
|
throw new BrowserAutomationError("INVALID_REF", "element ref must come from a fresh browser snapshot");
|
|
@@ -171,6 +225,12 @@ export class BrowserAutomationService {
|
|
|
171
225
|
else if (request.type === "press") {
|
|
172
226
|
await locator.press(request.key, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
173
227
|
}
|
|
228
|
+
else if (request.type === "select") {
|
|
229
|
+
await locator.selectOption(request.options, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
230
|
+
}
|
|
231
|
+
else if (request.type === "setChecked") {
|
|
232
|
+
await locator.setChecked(request.checked, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
233
|
+
}
|
|
174
234
|
else {
|
|
175
235
|
await locator.hover({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
176
236
|
}
|
|
@@ -184,6 +244,21 @@ export class BrowserAutomationService {
|
|
|
184
244
|
await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
|
|
185
245
|
return { action: request.type, ref, page: await this.snapshot({ screenshot: false }) };
|
|
186
246
|
}
|
|
247
|
+
/** Reading diagnostics never launches a browser or consumes snapshot/download state. */
|
|
248
|
+
consoleMessages(query = {}) {
|
|
249
|
+
return this.diagnostics.console(query, this.diagnosticTabId(query.tabId));
|
|
250
|
+
}
|
|
251
|
+
networkRequests(query = {}) {
|
|
252
|
+
return this.diagnostics.network(query, this.diagnosticTabId(query.tabId));
|
|
253
|
+
}
|
|
254
|
+
diagnosticTabId(tabId) {
|
|
255
|
+
if (tabId !== undefined) {
|
|
256
|
+
if (!this.tabs.has(tabId))
|
|
257
|
+
throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
|
|
258
|
+
return tabId;
|
|
259
|
+
}
|
|
260
|
+
return this.activePage ? tabIdForValue(this.tabs, this.activePage) ?? null : null;
|
|
261
|
+
}
|
|
187
262
|
async scroll(input) {
|
|
188
263
|
const page = await this.page();
|
|
189
264
|
const amount = clamp(input.amount ?? 600, 50, 3000);
|
|
@@ -201,7 +276,7 @@ export class BrowserAutomationService {
|
|
|
201
276
|
const page = this.tabs.get(tabId);
|
|
202
277
|
if (!page)
|
|
203
278
|
throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
|
|
204
|
-
this.
|
|
279
|
+
this.selectPage(page);
|
|
205
280
|
await page.bringToFront();
|
|
206
281
|
return this.snapshot({ screenshot: false });
|
|
207
282
|
}
|
|
@@ -209,7 +284,7 @@ export class BrowserAutomationService {
|
|
|
209
284
|
const context = await this.context();
|
|
210
285
|
const page = await context.newPage();
|
|
211
286
|
this.trackPage(page);
|
|
212
|
-
this.
|
|
287
|
+
this.selectPage(page);
|
|
213
288
|
if (rawUrl) {
|
|
214
289
|
const result = await this.navigate(rawUrl);
|
|
215
290
|
if (!result.ok)
|
|
@@ -222,10 +297,12 @@ export class BrowserAutomationService {
|
|
|
222
297
|
const page = tabId === undefined ? await this.page() : this.tabs.get(tabId);
|
|
223
298
|
if (!page)
|
|
224
299
|
throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
|
|
225
|
-
this.tabs.delete(tabIdForValue(this.tabs, page) ?? 0);
|
|
226
300
|
await page.close();
|
|
227
301
|
if (this.activePage === page)
|
|
228
302
|
this.activePage = undefined;
|
|
303
|
+
const current = this.operations.current;
|
|
304
|
+
if (current?.target.page === page)
|
|
305
|
+
current.target.page = undefined;
|
|
229
306
|
return this.snapshot({ screenshot: false });
|
|
230
307
|
}
|
|
231
308
|
async handleDialog(action, promptText) {
|
|
@@ -266,8 +343,8 @@ export class BrowserAutomationService {
|
|
|
266
343
|
await page.keyboard.type(text);
|
|
267
344
|
return { ok: true };
|
|
268
345
|
}
|
|
269
|
-
async screenshot() {
|
|
270
|
-
return this.snapshot({ screenshot: true });
|
|
346
|
+
async screenshot(options) {
|
|
347
|
+
return this.snapshot({ ...options, screenshot: true });
|
|
271
348
|
}
|
|
272
349
|
async focus() {
|
|
273
350
|
const page = await this.page();
|
|
@@ -279,12 +356,135 @@ export class BrowserAutomationService {
|
|
|
279
356
|
await page.waitForTimeout(clamp(ms, 0, 10_000));
|
|
280
357
|
return this.snapshot({ screenshot: false });
|
|
281
358
|
}
|
|
359
|
+
async pick(input) {
|
|
360
|
+
if (this.options.headless === true)
|
|
361
|
+
throw new BrowserAutomationError("PICK_UNAVAILABLE", "human picking requires the visible external browser");
|
|
362
|
+
const page = await this.page();
|
|
363
|
+
await page.bringToFront();
|
|
364
|
+
const element = await pickBrowserElement(page, input.message, clamp(input.timeoutMs ?? 30_000, 1, 60_000), this.operations.current?.signal).catch((error) => {
|
|
365
|
+
if (error instanceof Error && "code" in error && error.code === "PAGE_BUSY")
|
|
366
|
+
this.recovering.add(page);
|
|
367
|
+
throw error;
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
const snapshot = await this.snapshot({ screenshot: false });
|
|
371
|
+
for (const match of snapshot.ariaSnapshot.matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
|
|
372
|
+
const ref = match[1];
|
|
373
|
+
if (!ref)
|
|
374
|
+
continue;
|
|
375
|
+
const same = await page.locator(`aria-ref=${ref}`).evaluate((candidate, selected) => candidate === selected, element).catch(() => false);
|
|
376
|
+
if (same)
|
|
377
|
+
return { ref, tabId: snapshot.tabId, snapshotId: snapshot.snapshotId, page: snapshot };
|
|
378
|
+
}
|
|
379
|
+
throw new BrowserAutomationError("PICK_UNSUPPORTED", "selected element has no current ARIA ref (including unsupported frames); select an accessible control or inspect with eval");
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
await element.dispose();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async find(input) {
|
|
386
|
+
if (!input.text.trim())
|
|
387
|
+
throw new BrowserAutomationError("EMPTY_QUERY", "find requires visible text");
|
|
388
|
+
const page = await this.page();
|
|
389
|
+
const snapshot = await this.snapshot({ screenshot: false });
|
|
390
|
+
const candidates = [];
|
|
391
|
+
const limit = clamp(input.limit ?? 20, 1, 100);
|
|
392
|
+
let matchCount = 0;
|
|
393
|
+
for (const line of snapshot.ariaSnapshot.split("\n")) {
|
|
394
|
+
const match = /^\s*- (\w+)(?: "(.*?)")?[^\n]*?\[ref=((?:f\d+)?e\d+)\]/.exec(line);
|
|
395
|
+
if (!match?.[1] || !match[3])
|
|
396
|
+
continue;
|
|
397
|
+
const role = match[1];
|
|
398
|
+
const name = match[2] ?? "";
|
|
399
|
+
if (input.role && role !== input.role)
|
|
400
|
+
continue;
|
|
401
|
+
if (!name.toLocaleLowerCase().includes(input.text.toLocaleLowerCase()))
|
|
402
|
+
continue;
|
|
403
|
+
const locator = page.locator(`aria-ref=${match[3]}`);
|
|
404
|
+
if (!await locator.isVisible())
|
|
405
|
+
continue;
|
|
406
|
+
matchCount += 1;
|
|
407
|
+
if (candidates.length >= limit)
|
|
408
|
+
continue;
|
|
409
|
+
const context = await locator.evaluate((element) => (element.closest('tr,[role="row"],li,fieldset,section,[role="dialog"]')?.textContent ?? element.parentElement?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 500));
|
|
410
|
+
candidates.push({ ref: match[3], role, name, context });
|
|
411
|
+
}
|
|
412
|
+
return { tabId: snapshot.tabId, snapshotId: snapshot.snapshotId, url: snapshot.url, candidates, matchCount, truncated: matchCount > candidates.length || snapshot.ariaSnapshot.includes("[snapshot truncated]") };
|
|
413
|
+
}
|
|
414
|
+
async content(input = {}) {
|
|
415
|
+
const page = await this.page();
|
|
416
|
+
if (input.ref)
|
|
417
|
+
this.checkSnapshot(page, input.snapshotId);
|
|
418
|
+
if (input.ref && !/^(f\d+)?e\d+$/.test(input.ref))
|
|
419
|
+
throw new BrowserAutomationError("INVALID_REF", "content region requires a current ref");
|
|
420
|
+
const html = await captureBrowserHtml(page.locator(input.ref ? `aria-ref=${input.ref}` : "html"));
|
|
421
|
+
return { tabId: this.trackPage(page), url: page.url(), ...browserHtmlToMarkdown(html, page.url(), input.mode) };
|
|
422
|
+
}
|
|
423
|
+
async cookies(includeValues = false) {
|
|
424
|
+
const context = await this.context();
|
|
425
|
+
const cookies = await context.cookies();
|
|
426
|
+
return { cookies: cookies.map(({ value, ...cookie }) => includeValues ? { ...cookie, value } : cookie), valuesIncluded: includeValues };
|
|
427
|
+
}
|
|
428
|
+
async exportStorage() {
|
|
429
|
+
return exportBrowserStorage(await this.context(), path.join(this.options.profileDir, "..", "storage"));
|
|
430
|
+
}
|
|
431
|
+
async importStorage(filename, mode) {
|
|
432
|
+
if (mode !== "merge")
|
|
433
|
+
throw new BrowserAutomationError("INVALID_STORAGE_MODE", "explicit merge mode is required; existing entries not listed in the file are preserved");
|
|
434
|
+
const state = await readBrowserStorage(filename, path.join(this.options.profileDir, "..", "storage"));
|
|
435
|
+
for (const page of this.tabs.values()) {
|
|
436
|
+
if (this.recovering.has(page))
|
|
437
|
+
throw new BrowserAutomationError("PAGE_BUSY", "wait for page scripts before importing login state");
|
|
438
|
+
this.snapshots.delete(page);
|
|
439
|
+
}
|
|
440
|
+
return mergeBrowserStorage(await this.context(), state);
|
|
441
|
+
}
|
|
442
|
+
async evaluate(expression, options = {}) {
|
|
443
|
+
if (!expression.trim() || expression.length > 100_000)
|
|
444
|
+
throw new BrowserAutomationError("INVALID_EXPRESSION", "provide a nonempty expression up to 100000 characters; use an async IIFE for statements");
|
|
445
|
+
const page = await this.page();
|
|
446
|
+
this.snapshots.delete(page);
|
|
447
|
+
this.recovering.add(page);
|
|
448
|
+
const pending = evaluateBrowserExpression(page, expression).finally(() => {
|
|
449
|
+
this.recovering.delete(page);
|
|
450
|
+
this.snapshots.delete(page);
|
|
451
|
+
});
|
|
452
|
+
return waitForBrowserOperation(pending, clamp(options.timeoutMs ?? 15_000, 1, 60_000), this.operations.current?.signal);
|
|
453
|
+
}
|
|
454
|
+
async waitUntil(input) {
|
|
455
|
+
if ([input.text, input.url, input.ref].filter((value) => value !== undefined).length !== 1) {
|
|
456
|
+
throw new BrowserAutomationError("INVALID_WAIT", "specify exactly one of text, url or ref");
|
|
457
|
+
}
|
|
458
|
+
const page = await this.page();
|
|
459
|
+
const timeout = clamp(input.timeoutMs ?? 15_000, 1, 60_000);
|
|
460
|
+
try {
|
|
461
|
+
if (input.url !== undefined)
|
|
462
|
+
await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
|
|
463
|
+
else if (input.text !== undefined)
|
|
464
|
+
await page.getByText(input.text, { exact: true }).first().waitFor({ state: input.state ?? "visible", timeout });
|
|
465
|
+
else {
|
|
466
|
+
this.checkSnapshot(page, input.snapshotId);
|
|
467
|
+
if (!/^(f\d+)?e\d+$/.test(input.ref ?? ""))
|
|
468
|
+
throw new BrowserAutomationError("INVALID_REF", "invalid wait ref");
|
|
469
|
+
await page.locator(`aria-ref=${input.ref}`).waitFor({ state: input.state ?? "visible", timeout });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (cause) {
|
|
473
|
+
if (cause instanceof BrowserAutomationError)
|
|
474
|
+
throw cause;
|
|
475
|
+
throw new BrowserAutomationError("WAIT_TIMEOUT", errorMessage(cause), { cause });
|
|
476
|
+
}
|
|
477
|
+
return this.snapshot({ screenshot: false });
|
|
478
|
+
}
|
|
282
479
|
async dispose() {
|
|
283
480
|
if (this.disposed)
|
|
284
481
|
return;
|
|
285
482
|
this.disposed = true;
|
|
286
483
|
const pending = this.browserPromise;
|
|
287
484
|
this.browserPromise = undefined;
|
|
485
|
+
this.diagnostics.reset();
|
|
486
|
+
this.dialogQueue.length = 0;
|
|
487
|
+
this.downloadQueue.length = 0;
|
|
288
488
|
this.tabs.clear();
|
|
289
489
|
this.activePage = undefined;
|
|
290
490
|
if (!pending)
|
|
@@ -292,18 +492,44 @@ export class BrowserAutomationService {
|
|
|
292
492
|
const context = await pending.catch(() => undefined);
|
|
293
493
|
await context?.close().catch(() => undefined);
|
|
294
494
|
}
|
|
495
|
+
checkSnapshot(page, snapshotId) {
|
|
496
|
+
if (this.operations.current && !snapshotId)
|
|
497
|
+
throw new BrowserAutomationError("INVALID_REF", "snapshotId is required for ref operations");
|
|
498
|
+
if (snapshotId !== undefined && this.snapshots.get(page) !== snapshotId) {
|
|
499
|
+
throw new BrowserAutomationError("STALE_REF", "snapshot is stale or belongs to another tab; take a fresh snapshot");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Updates the operation-local page as well as the UI's last-selected page. */
|
|
503
|
+
selectPage(page) {
|
|
504
|
+
this.activePage = page;
|
|
505
|
+
const current = this.operations.current;
|
|
506
|
+
if (current)
|
|
507
|
+
current.target.page = page;
|
|
508
|
+
}
|
|
295
509
|
async page() {
|
|
510
|
+
const bound = this.operations.current?.target.page;
|
|
511
|
+
if (bound) {
|
|
512
|
+
if (bound.isClosed())
|
|
513
|
+
throw new BrowserAutomationError("TAB_NOT_FOUND", "bound tab was closed; select a current tab");
|
|
514
|
+
if (this.recovering.has(bound))
|
|
515
|
+
throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
|
|
516
|
+
return bound;
|
|
517
|
+
}
|
|
296
518
|
const context = await this.context();
|
|
297
|
-
if (this.activePage && !this.activePage.isClosed())
|
|
519
|
+
if (this.activePage && !this.activePage.isClosed()) {
|
|
520
|
+
if (this.recovering.has(this.activePage))
|
|
521
|
+
throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
|
|
522
|
+
this.selectPage(this.activePage);
|
|
298
523
|
return this.activePage;
|
|
524
|
+
}
|
|
299
525
|
const existing = context.pages().find((page) => this.tabs.has(tabIdForValue(this.tabs, page) ?? -1));
|
|
300
526
|
if (existing) {
|
|
301
|
-
this.
|
|
527
|
+
this.selectPage(existing);
|
|
302
528
|
return existing;
|
|
303
529
|
}
|
|
304
530
|
const page = await context.newPage();
|
|
305
531
|
this.trackPage(page);
|
|
306
|
-
this.
|
|
532
|
+
this.selectPage(page);
|
|
307
533
|
return page;
|
|
308
534
|
}
|
|
309
535
|
async context() {
|
|
@@ -316,6 +542,10 @@ export class BrowserAutomationService {
|
|
|
316
542
|
mkdir(this.options.screenshotsDir, { recursive: true }),
|
|
317
543
|
]);
|
|
318
544
|
const browser = await this.launchBrowser();
|
|
545
|
+
if (this.disposed) {
|
|
546
|
+
await browser.close();
|
|
547
|
+
throw new BrowserAutomationError("BROWSER_CLOSED", "browser service is disposed");
|
|
548
|
+
}
|
|
319
549
|
this.wireContext(browser);
|
|
320
550
|
const page = browser.pages()[0] ?? await browser.newPage();
|
|
321
551
|
this.trackPage(page);
|
|
@@ -333,6 +563,9 @@ export class BrowserAutomationService {
|
|
|
333
563
|
}
|
|
334
564
|
}
|
|
335
565
|
wireContext(context) {
|
|
566
|
+
this.diagnostics.attachContext(context, (page) => this.trackPage(page));
|
|
567
|
+
for (const page of context.pages())
|
|
568
|
+
this.trackPage(page);
|
|
336
569
|
context.on("page", (page) => {
|
|
337
570
|
this.trackPage(page);
|
|
338
571
|
if (!this.activePage || this.activePage.isClosed())
|
|
@@ -344,6 +577,9 @@ export class BrowserAutomationService {
|
|
|
344
577
|
if (current === context) {
|
|
345
578
|
this.browserPromise = undefined;
|
|
346
579
|
this.activePage = undefined;
|
|
580
|
+
this.diagnostics.reset();
|
|
581
|
+
this.dialogQueue.length = 0;
|
|
582
|
+
this.downloadQueue.length = 0;
|
|
347
583
|
this.tabs.clear();
|
|
348
584
|
}
|
|
349
585
|
});
|
|
@@ -385,7 +621,10 @@ export class BrowserAutomationService {
|
|
|
385
621
|
const id = this.nextTabId;
|
|
386
622
|
this.nextTabId += 1;
|
|
387
623
|
this.tabs.set(id, page);
|
|
624
|
+
this.diagnostics.trackPage(page, id);
|
|
625
|
+
page.on("framenavigated", () => this.snapshots.delete(page));
|
|
388
626
|
page.on("close", () => {
|
|
627
|
+
this.diagnostics.removePage(id);
|
|
389
628
|
this.tabs.delete(id);
|
|
390
629
|
if (this.activePage === page)
|
|
391
630
|
this.activePage = undefined;
|
|
@@ -407,19 +646,34 @@ export class BrowserAutomationService {
|
|
|
407
646
|
return Promise.all([...this.tabs.entries()].map(async ([id, page]) => ({
|
|
408
647
|
id,
|
|
409
648
|
url: page.url(),
|
|
410
|
-
title: page.isClosed() ? "" : await page.title().catch(() => ""),
|
|
649
|
+
title: page.isClosed() || this.recovering.has(page) ? "" : await page.title().catch(() => ""),
|
|
411
650
|
active: !page.isClosed() && page === this.activePage,
|
|
412
651
|
})));
|
|
413
652
|
}
|
|
414
|
-
async
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
653
|
+
async readScreenshot(filename) {
|
|
654
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/.test(filename)) {
|
|
655
|
+
throw new BrowserAutomationError("SCREENSHOT_NOT_FOUND", "invalid screenshot identity");
|
|
656
|
+
}
|
|
657
|
+
const handle = await open(path.join(this.options.screenshotsDir, filename), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
658
|
+
try {
|
|
659
|
+
const stat = await handle.stat();
|
|
660
|
+
if (!stat.isFile() || stat.size > 32 * 1024 * 1024)
|
|
661
|
+
throw new BrowserAutomationError("SCREENSHOT_TOO_LARGE", "screenshot exceeds the 32 MiB preview limit");
|
|
662
|
+
return await handle.readFile();
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
await handle.close();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async captureScreenshot(page, fullPage = false) {
|
|
669
|
+
const buffer = await page.screenshot({ type: "png", fullPage, timeout: 15_000 });
|
|
670
|
+
const screenshotPath = path.join(this.options.screenshotsDir, `${randomUUID()}.png`);
|
|
671
|
+
await writeFile(screenshotPath, buffer, { mode: 0o600, flag: "wx" });
|
|
672
|
+
const screenshotUrl = this.options.workspaceId ? `/api/operator/v1/chat/browser/screenshots/${path.basename(screenshotPath)}?workspaceId=${encodeURIComponent(this.options.workspaceId)}` : undefined;
|
|
673
|
+
if (buffer.byteLength > MAX_SCREENSHOT_BYTES) {
|
|
674
|
+
return { screenshotPath, screenshotUrl, screenshotError: "SCREENSHOT_TOO_LARGE: image exceeds 4 MiB; evidence saved, request a viewport screenshot" };
|
|
675
|
+
}
|
|
676
|
+
return { screenshotPath, screenshotUrl, screenshot: `data:image/png;base64,${buffer.toString("base64")}` };
|
|
423
677
|
}
|
|
424
678
|
async nextDownloadResult() {
|
|
425
679
|
const index = this.downloadQueue.findIndex((entry) => !entry.consumed);
|
|
@@ -446,6 +700,25 @@ export class BrowserAutomationService {
|
|
|
446
700
|
}
|
|
447
701
|
}
|
|
448
702
|
}
|
|
703
|
+
function validateBrowserInput(request) {
|
|
704
|
+
if (request.type === "setChecked" && typeof request.checked !== "boolean") {
|
|
705
|
+
throw new BrowserAutomationError("INVALID_ACTION", "setChecked requires an explicit checked boolean");
|
|
706
|
+
}
|
|
707
|
+
if (request.type !== "select")
|
|
708
|
+
return;
|
|
709
|
+
if (!Array.isArray(request.options) || request.options.length > 100 || !request.options.every(validSelectOption)) {
|
|
710
|
+
throw new BrowserAutomationError("INVALID_ACTION", "select requires up to 100 options, each with exactly one value, label or non-negative index");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function validSelectOption(option) {
|
|
714
|
+
if (!option || typeof option !== "object" || Object.keys(option).length !== 1)
|
|
715
|
+
return false;
|
|
716
|
+
if ("value" in option)
|
|
717
|
+
return typeof option.value === "string";
|
|
718
|
+
if ("label" in option)
|
|
719
|
+
return typeof option.label === "string";
|
|
720
|
+
return "index" in option && Number.isSafeInteger(option.index) && option.index >= 0;
|
|
721
|
+
}
|
|
449
722
|
function emptyBrowserState() {
|
|
450
723
|
return {
|
|
451
724
|
running: false,
|
|
@@ -467,6 +740,31 @@ function chromiumLaunchOptions(headless) {
|
|
|
467
740
|
ignoreDefaultArgs: ["--enable-automation"],
|
|
468
741
|
};
|
|
469
742
|
}
|
|
743
|
+
/**
|
|
744
|
+
* Bounded settle after a committed navigation. `domcontentloaded` fires before
|
|
745
|
+
* late client-side redirects (for example an engine that reroutes to a regional
|
|
746
|
+
* host), so a trailing short stability window keeps the next operation from
|
|
747
|
+
* racing an in-flight navigation. The cap keeps slow or long-polling pages from
|
|
748
|
+
* stalling the tool.
|
|
749
|
+
*/
|
|
750
|
+
async function settleNavigation(page) {
|
|
751
|
+
const deadline = Date.now() + NAVIGATION_SETTLE_CAP_MS;
|
|
752
|
+
let lastUrl = page.url();
|
|
753
|
+
let stableSince = Date.now();
|
|
754
|
+
while (Date.now() < deadline) {
|
|
755
|
+
await page.waitForTimeout(100).catch(() => undefined);
|
|
756
|
+
if (page.isClosed())
|
|
757
|
+
return;
|
|
758
|
+
const current = page.url();
|
|
759
|
+
if (current !== lastUrl) {
|
|
760
|
+
lastUrl = current;
|
|
761
|
+
stableSince = Date.now();
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (Date.now() - stableSince >= NAVIGATION_SETTLE_WINDOW_MS)
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
470
768
|
async function findSearchInput(page) {
|
|
471
769
|
for (const selector of SEARCH_INPUT_SELECTORS) {
|
|
472
770
|
const locator = page.locator(selector).first();
|
|
@@ -475,13 +773,20 @@ async function findSearchInput(page) {
|
|
|
475
773
|
}
|
|
476
774
|
return undefined;
|
|
477
775
|
}
|
|
478
|
-
async function submitSearchForm(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
776
|
+
async function submitSearchForm(input) {
|
|
777
|
+
// Prefer a submit control INSIDE the input's owning form. A page-level
|
|
778
|
+
// `button:not([type])` fallback can click an unrelated chrome button (for
|
|
779
|
+
// example a headful search engine renders extra visible buttons), which
|
|
780
|
+
// looks like a submit but never navigates.
|
|
781
|
+
const form = input.locator("xpath=ancestor-or-self::form[1]");
|
|
782
|
+
if (await form.count().catch(() => 0) > 0) {
|
|
783
|
+
for (const selector of SEARCH_SUBMIT_SELECTORS) {
|
|
784
|
+
const locator = form.locator(selector).first();
|
|
785
|
+
if (!await locator.isVisible().catch(() => false))
|
|
786
|
+
continue;
|
|
787
|
+
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
485
790
|
}
|
|
486
791
|
await input.press("Enter", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
487
792
|
}
|