@tea-agent/loop-agent 0.44.0-next.11 → 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.
Files changed (132) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/init.js +1 -0
  4. package/dist/executors/dag-pi-executor.js +11 -6
  5. package/dist/executors/pi-writer-tool-policy.js +60 -4
  6. package/dist/executors/playwright-cli-launcher.js +31 -45
  7. package/dist/executors/shell-executor.js +39 -11
  8. package/dist/executors/shell-write-guard.js +24 -67
  9. package/dist/governance/exec-plans.js +10 -1
  10. package/dist/task/config-types.js +4 -2
  11. package/dist/task/source-prepare/build-draft.js +1 -0
  12. package/dist/task/source-prepare/path-policy.js +17 -7
  13. package/dist/worker/console/chat/browser-automation.js +343 -38
  14. package/dist/worker/console/chat/browser-content.js +74 -0
  15. package/dist/worker/console/chat/browser-diagnostics.js +250 -0
  16. package/dist/worker/console/chat/browser-evaluation.js +58 -0
  17. package/dist/worker/console/chat/browser-operation-queue.js +19 -0
  18. package/dist/worker/console/chat/browser-pick.js +113 -0
  19. package/dist/worker/console/chat/browser-routes.js +33 -5
  20. package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
  21. package/dist/worker/console/chat/browser-storage.js +89 -0
  22. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
  23. package/dist/worker/console/chat/pi-runtime.js +1 -1
  24. package/dist/worker/console/chat/repo-browser.js +80 -2
  25. package/dist/worker/console/chat/repo-preview-kind.js +155 -0
  26. package/dist/worker/console/chat/session-mode.js +1 -1
  27. package/dist/worker/console/chat/session-store.js +4 -2
  28. package/dist/worker/console/chat/tools.js +12 -0
  29. package/dist/worker/console/routes.js +5 -0
  30. package/dist/worker/console/static/app-icon.svg +39 -0
  31. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-BA7qCL_r.js} +1 -1
  32. package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-Df09ajwL.js} +1 -1
  33. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CpF9sw5R.js} +1 -1
  34. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-BtPys8ja.js} +1 -1
  35. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DZPnTWqT.js} +1 -1
  36. package/dist/worker/console/static/assets/channel-DiTNLZUm.js +1 -0
  37. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-BD9D4h1p.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-Svn8yBmn.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-B8EbQj-S.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-tENZpcpF.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-DBx71Lco.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-1v_QamMz.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-BAMCNNBG.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BvuEury-.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-YqFuHp8s.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-ouI5T7Mr.js} +1 -1
  47. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +1 -0
  48. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +1 -0
  49. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-Bri0_Izk.js} +1 -1
  50. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-e8Vhhi4X.js} +1 -1
  51. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-tSIXGQiJ.js} +1 -1
  52. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-Rl-sF5pt.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-Db6ddPz8.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Jwd_Xw5S.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-CeC_8InC.js} +1 -1
  56. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-LKPRaNq8.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-DlVQytMW.js} +1 -1
  58. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-DnfJZwO-.js} +1 -1
  59. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-3axVSXca.js} +1 -1
  60. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-xe04-_L-.js} +1 -1
  61. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-hpmRINiD.js} +1 -1
  62. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js} +1 -1
  63. package/dist/worker/console/static/assets/index-C_8_gWal.css +1 -0
  64. package/dist/worker/console/static/assets/{index-CTMZsDmh.js → index-DybcVXCu.js} +102 -100
  65. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-BF6ksWTX.js} +1 -1
  66. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-DXisdeD7.js} +1 -1
  67. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BWu65wmu.js} +1 -1
  68. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-hfNhHqAI.js} +1 -1
  69. package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-D6HTfQcp.js} +1 -1
  70. package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-Iv91R1WP.js} +5 -5
  71. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CfPChwjR.js} +1 -1
  72. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-B7TcoqHz.js} +1 -1
  73. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-ouyPKMtQ.js} +1 -1
  74. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-B9tuyLbh.js} +1 -1
  75. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-Ck4q9nJs.js} +1 -1
  76. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-wXLX5Dnp.js} +1 -1
  77. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-D2XWw6_m.js} +1 -1
  78. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-DzxKiug4.js} +1 -1
  79. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-DWUqK5Us.js} +1 -1
  80. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-D5Pa4O5g.js} +1 -1
  81. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +1 -0
  82. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-VH7cRnsJ.js} +2 -2
  83. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +8 -0
  84. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-C4HM5NoZ.js} +1 -1
  85. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-Bu-4hOlA.js} +1 -1
  86. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-CasFEvvl.js} +1 -1
  87. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M-D-gpwPSA.js} +1 -1
  88. package/dist/worker/console/static/index.html +5 -2
  89. package/dist/worker/console/static/manifest.webmanifest +19 -0
  90. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
  91. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
  92. package/dist/worker/console/workspace-context.js +1 -0
  93. package/dist/worker/materialize/harness-task-lineage.js +4 -2
  94. package/dist/worker/observe/static/console-theme.css +33 -29
  95. package/dist/worker/observe/static/console-theme.js +38 -0
  96. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
  97. package/dist/workflows/dag/backend-test-layout.js +27 -7
  98. package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
  99. package/dist/workflows/dag/frontend-repair.js +24 -0
  100. package/dist/workflows/dag/frontend-review-scopes.js +10 -2
  101. package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
  102. package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
  103. package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
  104. package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
  105. package/dist/workflows/dag/frontend-test-layout.js +41 -12
  106. package/dist/workflows/dag/hybrid/sources.js +21 -2
  107. package/dist/workflows/dag/hybrid/templates/backend-test.js +65 -50
  108. package/dist/workflows/dag/hybrid/templates/frontend-test.js +58 -37
  109. package/dist/workflows/dag/test-artifact-namespace.js +73 -0
  110. package/dist/workflows/dag/validate.js +22 -3
  111. package/docs/architecture/dag-execution.md +3 -3
  112. package/docs/templates/README.md +4 -4
  113. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
  114. package/docs/templates/backend-test-dag.json +708 -708
  115. package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
  116. package/docs/templates/frontend-test-case-checklist.md +2 -2
  117. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
  118. package/docs/templates/frontend-test-dag.json +46 -37
  119. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
  120. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  121. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  122. package/package.json +7 -2
  123. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  124. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  125. package/skills/playwright-cli/SKILL.md +1 -1
  126. package/skills/playwright-cli-case-generator/SKILL.md +4 -4
  127. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
  128. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
  130. package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
  131. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
  132. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
@@ -1,5 +1,6 @@
1
1
  import { canonicalizePath } from "../contract/canonicalize.js";
2
2
  import { PREPARE_DEFAULT_FORBIDDEN_PATHS } from "./types.js";
3
+ import { isAnyTestArtifactPath, isTestArtifactTaskKind, } from "../../workflows/dag/test-artifact-namespace.js";
3
4
  function hasNul(value) {
4
5
  return value.includes("\0");
5
6
  }
@@ -133,6 +134,7 @@ export function assessPreparePaths(input) {
133
134
  forbidden.push(result.value);
134
135
  }
135
136
  const allowedSet = new Set(allowed);
137
+ const testArtifactTaskKind = isTestArtifactTaskKind(input.taskKind);
136
138
  for (const f of forbidden) {
137
139
  if (allowedSet.has(f)) {
138
140
  blocking.push({
@@ -145,14 +147,22 @@ export function assessPreparePaths(input) {
145
147
  }
146
148
  for (const a of allowed) {
147
149
  for (const f of forbidden) {
148
- if (forbiddenAncestorCoversAllowed(a, f)) {
149
- blocking.push({
150
- code: "ALLOWED_UNDER_FORBIDDEN_ANCESTOR",
151
- level: "blocking",
152
- message: `allowed path ${a} is fully covered by forbidden ancestor ${f}`,
153
- path: a,
154
- });
150
+ if (!forbiddenAncestorCoversAllowed(a, f))
151
+ continue;
152
+ // D3 test-artifact exception: a dedicated test task may allow exactly
153
+ // the canonical `.harness/testcase` roots under the default `.harness/**`
154
+ // forbidden ancestor; every other overlap stays blocking.
155
+ if (testArtifactTaskKind &&
156
+ f === ".harness/**" &&
157
+ isAnyTestArtifactPath(a)) {
158
+ continue;
155
159
  }
160
+ blocking.push({
161
+ code: "ALLOWED_UNDER_FORBIDDEN_ANCESTOR",
162
+ level: "blocking",
163
+ message: `allowed path ${a} is fully covered by forbidden ancestor ${f}`,
164
+ path: a,
165
+ });
156
166
  }
157
167
  }
158
168
  for (const a of allowed) {
@@ -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 { mkdir } from "node:fs/promises";
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.baidu.com/";
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
- : { screenshot: await this.captureScreenshot(page) }),
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
- ? undefined
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
- ...(screenshot ? { screenshot } : {}),
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
- const page = await this.page();
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
- await Promise.all([
152
- page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
153
- submitSearchForm(page, input),
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.activePage = page;
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.activePage = page;
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.activePage = existing;
527
+ this.selectPage(existing);
302
528
  return existing;
303
529
  }
304
530
  const page = await context.newPage();
305
531
  this.trackPage(page);
306
- this.activePage = page;
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 captureScreenshot(page) {
415
- const buffer = await page.screenshot({
416
- type: "png",
417
- fullPage: false,
418
- timeout: 15_000,
419
- });
420
- if (buffer.byteLength > MAX_SCREENSHOT_BYTES)
421
- return undefined;
422
- return `data:image/png;base64,${buffer.toString("base64")}`;
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(page, input) {
479
- for (const selector of SEARCH_SUBMIT_SELECTORS) {
480
- const locator = page.locator(selector).first();
481
- if (!await locator.isVisible().catch(() => false))
482
- continue;
483
- await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
484
- return;
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
  }