@skyramp/mcp 0.2.1-rc.1 → 0.2.2

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 (32) hide show
  1. package/build/playwright/registerPlaywrightTools.js +10 -0
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.js +98 -87
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +92 -60
  4. package/build/prompts/test-maintenance/driftAnalysisSections.js +139 -197
  5. package/build/prompts/test-recommendation/scopeAssessment.js +106 -5
  6. package/build/prompts/test-recommendation/scopeAssessment.test.js +128 -1
  7. package/build/prompts/testbot/testbot-prompts.js +6 -9
  8. package/build/prompts/testbot/testbot-prompts.test.js +38 -22
  9. package/build/services/TestDiscoveryService.js +39 -9
  10. package/build/tools/test-management/actionsTool.js +166 -148
  11. package/build/tools/test-management/analyzeChangesTool.js +10 -12
  12. package/build/tools/test-management/analyzeTestHealthTool.js +10 -22
  13. package/build/tools/test-management/uiAnalyzeChangesTool.js +8 -2
  14. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +47 -0
  15. package/build/utils/dartRouteExtractor.js +319 -0
  16. package/build/utils/dartRouteExtractor.test.js +307 -0
  17. package/build/utils/docker.test.js +1 -1
  18. package/build/utils/uiPageEnumerator.js +67 -0
  19. package/build/utils/uiPageEnumerator.test.js +222 -0
  20. package/build/utils/versions.js +1 -1
  21. package/node_modules/playwright/lib/mcp/skyramp/assertApiRequestTool.js +46 -0
  22. package/node_modules/playwright/lib/mcp/skyramp/index.js +10 -0
  23. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +313 -0
  24. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +146 -0
  25. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +519 -52
  26. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +32 -14
  27. package/package.json +2 -2
  28. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +0 -261
  29. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  30. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  31. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  32. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
@@ -43,6 +43,10 @@ var import_log = require("../log");
43
43
  var import_skyRampExport = require("../test/skyRampExport");
44
44
  var import_exportTool = require("./exportTool");
45
45
  var import_assertTool = require("./assertTool");
46
+ var import_assertApiRequestTool = require("./assertApiRequestTool");
47
+ var import_loadTraceTool = require("./loadTraceTool");
48
+ var import_skyRampImport = require("./skyRampImport");
49
+ var import_utils = require("playwright-core/lib/utils");
46
50
  var import_types = require("./types");
47
51
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
48
52
  class TraceRecordingBackend {
@@ -115,7 +119,7 @@ class TraceRecordingBackend {
115
119
  }
116
120
  async listTools() {
117
121
  const browserTools = await this._browserBackend.listTools();
118
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)()];
122
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)()];
119
123
  }
120
124
  async callTool(name, args, progress) {
121
125
  if (!this._initialized)
@@ -138,10 +142,27 @@ class TraceRecordingBackend {
138
142
  }
139
143
  return exportResult;
140
144
  }
145
+ if (name === import_loadTraceTool.loadTraceSchema.name) {
146
+ const parsed = import_loadTraceTool.loadTraceSchema.inputSchema.parse(args || {});
147
+ return this._handleLoadTrace(parsed);
148
+ }
141
149
  if (name === import_assertTool.assertToolSchema.name) {
142
150
  const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
143
151
  return this._handleAssert(parsed);
144
152
  }
153
+ if (name === import_assertApiRequestTool.assertApiRequestSchema.name) {
154
+ this._trackedActions.push({
155
+ toolName: "browser_assert_api_request",
156
+ args: {},
157
+ code: "",
158
+ timestamp: Date.now(),
159
+ pageAlias: this._currentPageAlias
160
+ });
161
+ traceDebug("Assert API request marker recorded");
162
+ return {
163
+ content: [{ type: "text", text: "### Result\nAPI request assertion marker recorded. The next action that triggers a network request will have its payload captured for validation in the generated test." }]
164
+ };
165
+ }
145
166
  if (name === "browser_select_option") {
146
167
  const result2 = await this._handleSelectOption(args || {});
147
168
  return result2;
@@ -178,6 +199,7 @@ class TraceRecordingBackend {
178
199
  timestamp: ts,
179
200
  pageAlias: this._currentPageAlias
180
201
  });
202
+ await this._enableFlutterAccessibility();
181
203
  const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
182
204
  return {
183
205
  content: [
@@ -191,17 +213,88 @@ Reloaded current page: ${currentUrl}
191
213
  }
192
214
  const timestampBeforeAction = Date.now();
193
215
  const pageAliasBeforeAction = this._currentPageAlias;
216
+ const stripRestore = await this._maybeStripWrapperRoles(name, args);
194
217
  let preClickHoverCode = null;
195
- if (name === "browser_click" && args?.ref) {
196
- const hoverResult = await this._browserBackend.callTool("browser_hover", {
197
- element: args.element || "element",
198
- ref: args.ref
199
- });
200
- if (!hoverResult.isError)
201
- preClickHoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? null;
218
+ let preClickSnapshot = null;
219
+ let preClickLocatorCount = 0;
220
+ let preClickParentChain = null;
221
+ let result;
222
+ try {
223
+ if (name === "browser_click" && args?.ref) {
224
+ const hoverResult = await this._browserBackend.callTool("browser_hover", {
225
+ element: args.element || "element",
226
+ ref: args.ref
227
+ });
228
+ if (!hoverResult.isError) {
229
+ const parsed = (0, import_response.parseResponse)(hoverResult);
230
+ preClickHoverCode = parsed?.code ?? null;
231
+ preClickSnapshot = parsed?.snapshot ?? null;
232
+ if (preClickHoverCode) {
233
+ const roleCountMatch = preClickHoverCode.match(/getByRole\(\s*['"](\w+)['"]\s*(?:,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")\s*[^}]*\})?\s*\)/);
234
+ if (roleCountMatch) {
235
+ try {
236
+ const page = this._browserBackend.context?.currentTab()?.page;
237
+ if (page) {
238
+ const r = roleCountMatch[1];
239
+ const n = roleCountMatch[2] ?? roleCountMatch[3];
240
+ preClickLocatorCount = n ? await page.getByRole(r, { name: n }).count() : await page.getByRole(r).count();
241
+ traceDebug(`[pre-click count] getByRole("${r}"${n ? `, { name: "${n}" }` : ""}) = ${preClickLocatorCount}`);
242
+ }
243
+ } catch {
244
+ }
245
+ }
246
+ }
247
+ try {
248
+ const page = this._browserBackend.context?.currentTab()?.page;
249
+ if (page) {
250
+ preClickParentChain = await page.locator(`aria-ref=${args.ref}`).evaluate((el) => {
251
+ const ARIA_LANDMARKS = /* @__PURE__ */ new Set(["navigation", "main", "banner", "contentinfo", "complementary", "search", "form"]);
252
+ const SEMANTIC_TAGS_UNIQUE_CANDIDATES = ["nav", "main", "header", "footer", "aside"];
253
+ const SKIP_TESTID = /^(container|wrapper|content|main|root|app)$/i;
254
+ const SKIP_ID = /^(root|app|main|container|wrapper|content)$/i;
255
+ const isDynamic = (s) => /[-_]\d+$/.test(s) || /^[0-9a-f]{8,}/.test(s);
256
+ let cur = el.parentElement;
257
+ while (cur && cur !== el.ownerDocument.body) {
258
+ const tid = cur.getAttribute("data-testid") || cur.getAttribute("data-test-id");
259
+ if (tid && !isDynamic(tid) && !SKIP_TESTID.test(tid))
260
+ return `getByTestId(${JSON.stringify(tid)})`;
261
+ const id = cur.id;
262
+ if (id && !isDynamic(id) && !SKIP_ID.test(id)) {
263
+ const escapedId = id.replace(/(["\\])/g, "\\$1");
264
+ return `locator(${JSON.stringify("#" + escapedId)})`;
265
+ }
266
+ const role = cur.getAttribute("role");
267
+ if (role && ARIA_LANDMARKS.has(role)) {
268
+ const ariaLabel = cur.getAttribute("aria-label");
269
+ if (ariaLabel)
270
+ return `getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(ariaLabel)} })`;
271
+ if (el.ownerDocument.querySelectorAll(`[role="${role}"]`).length === 1)
272
+ return `getByRole(${JSON.stringify(role)})`;
273
+ }
274
+ const tag = cur.tagName.toLowerCase();
275
+ if (SEMANTIC_TAGS_UNIQUE_CANDIDATES.includes(tag)) {
276
+ if (el.ownerDocument.querySelectorAll(tag).length === 1)
277
+ return `locator(${JSON.stringify(tag)})`;
278
+ }
279
+ cur = cur.parentElement;
280
+ }
281
+ return null;
282
+ }).catch(() => null);
283
+ traceDebug(`[pre-click parent chain] ${preClickParentChain}`);
284
+ }
285
+ } catch {
286
+ }
287
+ }
288
+ }
289
+ result = await this._browserBackend.callTool(name, args);
290
+ } finally {
291
+ if (stripRestore)
292
+ await stripRestore();
202
293
  }
203
- let result = await this._browserBackend.callTool(name, args);
204
- result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode);
294
+ if (name === "browser_navigate" && !result.isError) {
295
+ await this._enableFlutterAccessibility();
296
+ }
297
+ result = await this._improveActionSelector(name, args || {}, result, preClickHoverCode, preClickSnapshot, preClickLocatorCount, preClickParentChain);
205
298
  if (name === "browser_fill_form" && result.isError) {
206
299
  const resultText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
207
300
  if (resultText.includes("not a <select> element") || resultText.includes("selectOption") && resultText.includes("Timeout")) {
@@ -227,6 +320,216 @@ Reloaded current page: ${currentUrl}
227
320
  serverClosed() {
228
321
  void this._autoExportAndClose().catch(import_log.logUnhandledError);
229
322
  }
323
+ /**
324
+ * Load a prior Skyramp trace and replay it against the live browser, honoring
325
+ * an optional stop point, then seed _trackedActions with the replayed actions
326
+ * so the model can continue recording and export a combined trace.
327
+ */
328
+ async _handleLoadTrace(params) {
329
+ const stopParams = ["stopAtStep", "stopAtUrl", "stopBefore"].filter((k) => {
330
+ const v = params[k];
331
+ return v !== void 0 && v !== "";
332
+ });
333
+ if (stopParams.length > 1)
334
+ return { content: [{ type: "text", text: `### Error
335
+ Pass at most ONE of stopAtStep / stopAtUrl / stopBefore (got ${stopParams.join(", ")}). Omit all three to replay the whole trace.` }], isError: true };
336
+ let loadedActions;
337
+ try {
338
+ loadedActions = await (0, import_skyRampImport.actionsFromPath)(params.path);
339
+ } catch (e) {
340
+ return { content: [{ type: "text", text: `### Error
341
+ Failed to load trace from ${params.path}: ${e.message}` }], isError: true };
342
+ }
343
+ if (!loadedActions.length)
344
+ return { content: [{ type: "text", text: `### Error
345
+ No actions found in ${params.path}.` }], isError: true };
346
+ if (params.dryRun) {
347
+ const stepList = loadedActions.map((a, i) => (0, import_loadTraceTool.describeStep)(a, i)).join("\n");
348
+ return {
349
+ content: [{
350
+ type: "text",
351
+ text: `### Trace loaded (dry run)
352
+ ${loadedActions.length} steps in ${params.path}. Nothing was replayed.
353
+
354
+ Steps:
355
+ ${stepList}
356
+
357
+ Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none of them to replay all) to replay.`
358
+ }]
359
+ };
360
+ }
361
+ try {
362
+ await this._browserBackend.context.ensureTab();
363
+ } catch {
364
+ }
365
+ let result;
366
+ try {
367
+ result = await (0, import_loadTraceTool.replayActions)(loadedActions, { pageForAlias: (alias) => this._pageForAlias(alias) }, params);
368
+ } catch (e) {
369
+ return { content: [{ type: "text", text: `### Error
370
+ Replay failed: ${e.message}` }], isError: true };
371
+ }
372
+ for (let i = 0; i < result.stopIndex; i++)
373
+ this._seedTrackedAction(loadedActions[i]);
374
+ if (result.stopIndex > 0)
375
+ this._currentPageAlias = loadedActions[result.stopIndex - 1].frame.pageAlias || "page";
376
+ const remaining = (0, import_loadTraceTool.listStepsFrom)(loadedActions, result.stopIndex);
377
+ const stoppedNote = (0, import_loadTraceTool.describeStopReason)(result);
378
+ const errorNote = result.error ? `
379
+
380
+ \u26A0\uFE0F Replay stopped at step ${result.error.stepIndex} (${result.error.actionName}): ${result.error.message}
381
+ You can continue recording from this point or fix the issue.` : "";
382
+ return {
383
+ content: [{
384
+ type: "text",
385
+ text: `### Trace replayed
386
+ ${result.completedCount} of ${loadedActions.length} steps replayed from ${params.path}. ${stoppedNote}${errorNote}
387
+
388
+ Remaining steps (NOT replayed):
389
+ ${remaining}
390
+
391
+ Continue recording with browser_* tools, then call skyramp_export_zip to write the combined trace.`
392
+ }],
393
+ isError: result.stopReason === "error"
394
+ };
395
+ }
396
+ /**
397
+ * Resolve a JSONL page alias to a live page. Numeric aliases ('page',
398
+ * 'pageN') map by tab index; semantic aliases ('popupPage') match by the
399
+ * popup URL pattern, falling back to the most recently opened tab.
400
+ */
401
+ _pageForAlias(alias) {
402
+ const tabs = this._browserBackend.context?.tabs() ?? [];
403
+ if (!tabs.length)
404
+ return void 0;
405
+ const numeric = alias === "page" ? 0 : /^page(\d+)$/.exec(alias)?.[1];
406
+ if (numeric !== void 0 && numeric !== null) {
407
+ const idx = Number(numeric);
408
+ return (tabs[idx] ?? tabs[tabs.length - 1]).page;
409
+ }
410
+ const popupTab = tabs.find((t) => /chrome-extension:\/\/[a-p]{32}\/popup\//.test(t.page.url()));
411
+ return (popupTab ?? tabs[tabs.length - 1]).page;
412
+ }
413
+ /**
414
+ * Convert a replayed ActionInContext back into a TrackedAction so it merges
415
+ * with freshly-recorded actions. The export pipeline (buildJsonlContent)
416
+ * re-derives the JSONL selector from the `code` field, so we regenerate clean
417
+ * Playwright code from the stored internal: selector via asLocator.
418
+ */
419
+ _seedTrackedAction(action) {
420
+ const a = action.action;
421
+ const pageAlias = action.frame.pageAlias || "page";
422
+ if (a.name === "navigate") {
423
+ this._trackedActions.push({
424
+ toolName: "browser_navigate",
425
+ args: { url: a.url },
426
+ code: "",
427
+ timestamp: action.startTime,
428
+ pageAlias
429
+ });
430
+ return;
431
+ }
432
+ const locatorExpr = a.selector ? (0, import_utils.asLocator)("javascript", a.selector) : "";
433
+ const seeded = this._seedTrackedActionFields(a, locatorExpr);
434
+ if (!seeded)
435
+ return;
436
+ this._trackedActions.push({
437
+ ...seeded,
438
+ timestamp: action.startTime,
439
+ pageAlias,
440
+ framePath: action.frame.framePath?.length ? action.frame.framePath : void 0
441
+ });
442
+ }
443
+ /**
444
+ * Build the { toolName, code, args } triple a seeded (replayed) action must
445
+ * carry so it round-trips through skyRampExport.buildJsonlContent exactly as
446
+ * a freshly-recorded action would. toolName, code and args are emitted
447
+ * together (not via separate mappers) because export keys on all three:
448
+ * trackedActionToJsonl dispatches on toolName, derives the locator from code,
449
+ * and reads specific args (checked, values, type). A mismatch silently
450
+ * mis-exports (e.g. hover as click) or drops the action.
451
+ *
452
+ * Code uses single quotes to match the live recorder's format
453
+ * (extractFillText / extractSelectOptions only match single-quoted calls).
454
+ * Returns null for unsupported actions so they are skipped, never defaulted.
455
+ */
456
+ _seedTrackedActionFields(a, locatorExpr) {
457
+ const sq = (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}'`;
458
+ const SELECTOR_REQUIRED = /* @__PURE__ */ new Set(["click", "hover", "fill", "pressSequentially", "check", "uncheck", "select"]);
459
+ if (SELECTOR_REQUIRED.has(a.name) && !locatorExpr)
460
+ return null;
461
+ const root = a.frameUrlMatch ? `page.frames().find(f => f.url().includes(${sq(a.frameUrlMatch)}))!` : "page";
462
+ switch (a.name) {
463
+ case "click": {
464
+ const isDouble = (a.clickCount ?? 1) > 1;
465
+ const verb = isDouble ? "dblclick" : "click";
466
+ const args = {};
467
+ if (a.button && a.button !== "left")
468
+ args.button = a.button;
469
+ const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
470
+ if (mods.length)
471
+ args.modifiers = mods;
472
+ return { toolName: "browser_click", code: `await ${root}.${locatorExpr}.${verb}();`, args };
473
+ }
474
+ case "hover":
475
+ return { toolName: "browser_hover", code: `await ${root}.${locatorExpr}.hover();`, args: {} };
476
+ case "fill":
477
+ return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.fill(${sq(a.text ?? "")});`, args: { text: a.text ?? "" } };
478
+ case "pressSequentially":
479
+ return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.pressSequentially(${sq(a.text ?? "")});`, args: { text: a.text ?? "", slowly: true } };
480
+ case "press": {
481
+ const args = { key: a.key };
482
+ const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
483
+ if (mods.length)
484
+ args.modifiers = mods;
485
+ return { toolName: "browser_press_key", code: "", args };
486
+ }
487
+ case "assertApiRequest":
488
+ return { toolName: "browser_assert_api_request", code: "", args: {} };
489
+ case "waitForTimeout":
490
+ return { toolName: "browser_wait_for", code: "", args: { time: (a.duration ?? 0) / 1e3 } };
491
+ case "check":
492
+ return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.check();`, args: { checked: true } };
493
+ case "uncheck":
494
+ return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.uncheck();`, args: { checked: false } };
495
+ case "select":
496
+ return { toolName: "browser_select_option", code: `await ${root}.${locatorExpr}.selectOption(${sq((a.options ?? [])[0] ?? "")});`, args: { values: a.options ?? [] } };
497
+ case "waitForSelector": {
498
+ const sel = a.selector ?? "";
499
+ const m = /^text=(.*)$/.exec(sel);
500
+ if (!m)
501
+ return null;
502
+ return a.state === "hidden" ? { toolName: "browser_wait_for", code: "", args: { textGone: m[1] } } : { toolName: "browser_wait_for", code: "", args: { text: m[1] } };
503
+ }
504
+ case "dragAndDrop":
505
+ case "dragTo": {
506
+ const srcSel = a.source ?? a.selector;
507
+ const tgtSel = a.target ?? a.targetSelector;
508
+ if (!srcSel || !tgtSel)
509
+ return null;
510
+ const srcExpr = (0, import_utils.asLocator)("javascript", srcSel);
511
+ const tgtExpr = (0, import_utils.asLocator)("javascript", tgtSel);
512
+ return { toolName: "browser_drag", code: `await page.${srcExpr}.dragTo(page.${tgtExpr});`, args: {} };
513
+ }
514
+ // Assertions are encoded the same way _handleAssert encodes them, so
515
+ // assertActionToJsonl in skyRampExport parses them identically. That
516
+ // parser dispatches on toolName === 'browser_assert' and keys on args.type.
517
+ // Assertions pass selector/expected as structured args (unambiguous even
518
+ // when the value contains ':'). assertActionToJsonl reads these directly;
519
+ // the legacy colon-encoded code string is kept only as a human-readable
520
+ // breadcrumb / backward-compat fallback.
521
+ case "assertText":
522
+ return { toolName: "browser_assert", code: `assertText:${a.selector}:${a.text}:${a.substring ?? true}`, args: { type: "text", selector: a.selector, expected: a.text, substring: a.substring ?? true } };
523
+ case "assertValue":
524
+ return { toolName: "browser_assert", code: `assertValue:${a.selector}:${a.value}`, args: { type: "value", selector: a.selector, expected: a.value } };
525
+ case "assertChecked":
526
+ return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
527
+ case "assertVisible":
528
+ return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
529
+ default:
530
+ return null;
531
+ }
532
+ }
230
533
  /**
231
534
  * Handle browser_select_option with automatic fallback for custom dropdowns.
232
535
  * 1. Try native selectOption first.
@@ -387,7 +690,10 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
387
690
  const assertName = params.type === "value" ? "assertValue" : "assertText";
388
691
  this._trackedActions.push({
389
692
  toolName: "browser_assert",
390
- args: { type: params.type, ref: params.ref, expected: params.expected },
693
+ // Pass selector/expected as structured args so assertActionToJsonl can
694
+ // decode unambiguously (the colon-encoded code below corrupts values
695
+ // that contain ':', e.g. URLs). The code string is a fallback only.
696
+ args: { type: params.type, ref: params.ref, selector: parsed.selector, expected: params.expected, ...params.type === "text" ? { substring: params.substring } : {} },
391
697
  code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
392
698
  timestamp
393
699
  });
@@ -563,7 +869,7 @@ ${details}` }]
563
869
  * preClickHoverCode: hover result taken BEFORE the click (needed for navigation clicks
564
870
  * where the element ref is no longer valid after navigation).
565
871
  */
566
- async _improveActionSelector(toolName, args, result, preClickHoverCode = null) {
872
+ async _improveActionSelector(toolName, args, result, preClickHoverCode = null, preClickSnapshot = null, preClickLocatorCount = 0, preClickParentChain = null) {
567
873
  if (result.isError)
568
874
  return result;
569
875
  const selectorTools = /* @__PURE__ */ new Set(["browser_click", "browser_hover", "browser_type", "browser_fill"]);
@@ -606,43 +912,64 @@ ${details}` }]
606
912
  }
607
913
  return result;
608
914
  }
609
- const roleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)(?!\s*\.(?:filter|nth|first|last))/);
610
- if (!roleMatch)
915
+ if (code.includes(".filter(") || code.includes(".nth(") || code.includes(".first(") || code.includes(".last("))
916
+ return result;
917
+ if (/page\.(getByTestId|locator|getByRole)\([^)]*\)\.getByRole/.test(code))
918
+ return result;
919
+ const bareRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*\)/);
920
+ const namedRoleMatch = code.match(/getByRole\(\s*['"](\w+)['"]\s*,\s*\{[^}]*name:\s*(?:'([^']*)'|"([^"]*)")/);
921
+ if (!bareRoleMatch && !namedRoleMatch)
611
922
  return result;
612
- const role = roleMatch[1];
923
+ const role = bareRoleMatch?.[1] ?? namedRoleMatch[1];
924
+ const roleName = namedRoleMatch ? namedRoleMatch[2] ?? namedRoleMatch[3] : void 0;
613
925
  const ref = args.ref;
614
926
  if (!ref)
615
927
  return result;
616
- const snapText = parsed?.snapshot ?? "";
617
- if (!snapText)
618
- return result;
619
- const snapLines = snapText.split("\n");
928
+ const snapText = preClickSnapshot || parsed?.snapshot || "";
929
+ const snapLines = (snapText || "").split("\n");
620
930
  const rolePattern = new RegExp(`^\\s*-\\s*${role}\\s+`, "m");
621
- const roleCount = snapLines.filter((l) => rolePattern.test(l)).length;
622
- if (roleCount <= 1) {
623
- traceDebug(`Role "${role}" is unique, no improvement needed`);
624
- return result;
931
+ let snapshotMatchCount = 0;
932
+ if (snapText) {
933
+ if (roleName) {
934
+ const escapedName = roleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
935
+ const namedPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${escapedName}`, "i");
936
+ snapshotMatchCount = snapLines.filter((l) => namedPattern.test(l)).length;
937
+ } else {
938
+ snapshotMatchCount = snapLines.filter((l) => rolePattern.test(l)).length;
939
+ }
625
940
  }
626
- traceDebug(`Role "${role}" appears ${roleCount} times \u2014 attempting to disambiguate`);
941
+ const ambiguousCount = Math.max(preClickLocatorCount, snapshotMatchCount);
942
+ traceDebug(`[disambig] role="${role}" name="${roleName ?? ""}" preClickCount=${preClickLocatorCount} snapshotCount=${snapshotMatchCount} \u2192 ambiguousCount=${ambiguousCount}`);
943
+ traceDebug(`Role "${role}"${roleName ? ` name="${roleName}"` : ""} matches ${ambiguousCount} elements \u2014 attempting to disambiguate`);
627
944
  const refPattern = `[ref=${ref}]`;
628
945
  const refIdx = snapLines.findIndex((l) => l.includes(refPattern));
629
946
  if (refIdx < 0)
630
947
  return result;
631
948
  const refLine = snapLines[refIdx];
632
- const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
633
- if (textMatch && textMatch[1]) {
634
- const visibleText = textMatch[1];
635
- const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
636
- const textCount = snapLines.filter((l) => textPattern.test(l)).length;
637
- if (textCount === 1) {
638
- const improvedCode = code.replace(
639
- /getByRole\((\s*['"](\w+)['"]\s*)\)/,
640
- `getByRole($1).filter({ hasText: "${visibleText}" })`
641
- );
642
- traceDebug(`Improved selector with text filter: ${visibleText}`);
643
- return this._modifyResultCode(result, improvedCode);
949
+ if (!roleName && ambiguousCount > 1) {
950
+ const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
951
+ if (textMatch && textMatch[1]) {
952
+ const visibleText = textMatch[1];
953
+ const textPattern = new RegExp(`^\\s*-\\s*${role}\\s+"[^"]*${visibleText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
954
+ const textCount = snapLines.filter((l) => textPattern.test(l)).length;
955
+ if (textCount === 1) {
956
+ const improvedCode = code.replace(
957
+ /getByRole\((\s*['"](\w+)['"]\s*)\)/,
958
+ `getByRole($1).filter({ hasText: "${visibleText}" })`
959
+ );
960
+ traceDebug(`Improved selector with text filter: ${visibleText}`);
961
+ return this._modifyResultCode(result, improvedCode);
962
+ }
644
963
  }
645
964
  }
965
+ if (preClickParentChain) {
966
+ const improvedCode = code.replace(
967
+ /await\s+page\./,
968
+ `await page.${preClickParentChain}.`
969
+ );
970
+ traceDebug(`Improved selector by chaining from parent: ${preClickParentChain}`);
971
+ return this._modifyResultCode(result, improvedCode);
972
+ }
646
973
  const refIndent = refLine.match(/^(\s*)/)?.[1]?.length ?? 0;
647
974
  for (let i = refIdx - 1; i >= 0; i--) {
648
975
  const line = snapLines[i];
@@ -667,7 +994,7 @@ ${details}` }]
667
994
  /await\s+page\./,
668
995
  `await page.getByTestId("${testid}").`
669
996
  );
670
- traceDebug(`Improved selector by chaining from test-id: ${testid}`);
997
+ traceDebug(`Improved selector by chaining from test-id (snapshot walk): ${testid}`);
671
998
  return this._modifyResultCode(result, improvedCode);
672
999
  }
673
1000
  }
@@ -676,22 +1003,24 @@ ${details}` }]
676
1003
  if (indent === 0)
677
1004
  break;
678
1005
  }
679
- const roleRefs = [];
680
- for (const line of snapLines) {
681
- if (rolePattern.test(line)) {
682
- const m = line.match(/\[ref=(\w+)\]/);
683
- if (m)
684
- roleRefs.push(m[1]);
1006
+ if (ambiguousCount > 1) {
1007
+ const roleRefs = [];
1008
+ for (const line of snapLines) {
1009
+ if (rolePattern.test(line)) {
1010
+ const m = line.match(/\[ref=(\w+)\]/);
1011
+ if (m)
1012
+ roleRefs.push(m[1]);
1013
+ }
1014
+ }
1015
+ const index = roleRefs.indexOf(ref);
1016
+ if (index >= 0) {
1017
+ const improvedCode = code.replace(
1018
+ /getByRole\((\s*['"][^'"]+['"](?:\s*,\s*\{[^}]*\})?)\s*\)/,
1019
+ `getByRole($1).nth(${index})`
1020
+ );
1021
+ traceDebug(`Improved selector with .nth(${index})`);
1022
+ return this._modifyResultCode(result, improvedCode);
685
1023
  }
686
- }
687
- const index = roleRefs.indexOf(ref);
688
- if (index >= 0) {
689
- const improvedCode = code.replace(
690
- /getByRole\((\s*['"](\w+)['"]\s*)\)/,
691
- `getByRole($1).nth(${index})`
692
- );
693
- traceDebug(`Improved selector with .nth(${index})`);
694
- return this._modifyResultCode(result, improvedCode);
695
1024
  }
696
1025
  return result;
697
1026
  }
@@ -779,6 +1108,51 @@ ${details}` }]
779
1108
  });
780
1109
  });
781
1110
  }
1111
+ /**
1112
+ * SKYR-3728: detect a wrapper-button around args.ref and, if found, strip
1113
+ * role="button"/role="link" from every wrapper in the document so that
1114
+ * upstream's closestCrossShadow can't retarget UP during selector
1115
+ * regeneration. Returns a restore function the caller MUST invoke (via
1116
+ * try/finally) once the click has been recorded.
1117
+ *
1118
+ * Works for browser_click, browser_type, browser_fill, browser_hover —
1119
+ * anything that goes through tab.refLocator → upstream generateSelector.
1120
+ */
1121
+ async _maybeStripWrapperRoles(toolName, args) {
1122
+ const interactiveTools = /* @__PURE__ */ new Set(["browser_click", "browser_type", "browser_fill", "browser_hover"]);
1123
+ if (!interactiveTools.has(toolName))
1124
+ return null;
1125
+ const ref = args?.ref;
1126
+ if (!ref)
1127
+ return null;
1128
+ const tab = this._browserBackend.context?.currentTab();
1129
+ const page = tab?.page;
1130
+ if (!page)
1131
+ return null;
1132
+ let stripped = false;
1133
+ try {
1134
+ const handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
1135
+ if (!handle)
1136
+ return null;
1137
+ try {
1138
+ stripped = await page.evaluate(pageEvaluateStripWrapperRoles, handle);
1139
+ } finally {
1140
+ await handle.dispose();
1141
+ }
1142
+ } catch {
1143
+ return null;
1144
+ }
1145
+ if (!stripped)
1146
+ return null;
1147
+ traceDebug(`Wrapper-leak detected for ref=${ref}; stripped role=button/link document-wide for the action`);
1148
+ return async () => {
1149
+ try {
1150
+ await page.evaluate(pageEvaluateRestoreWrapperRoles);
1151
+ traceDebug("Restored stripped roles");
1152
+ } catch {
1153
+ }
1154
+ };
1155
+ }
782
1156
  _maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
783
1157
  if (result.isError)
784
1158
  return;
@@ -809,6 +1183,54 @@ ${details}` }]
809
1183
  return url.replace(/\/$/, "");
810
1184
  }
811
1185
  }
1186
+ /**
1187
+ * Enable Flutter web accessibility mode by clicking the hidden
1188
+ * flt-semantics-placeholder button. Flutter web apps render to canvas by
1189
+ * default, making DOM automation impossible. This creates a parallel ARIA
1190
+ * tree that Playwright can interact with.
1191
+ */
1192
+ async _enableFlutterAccessibility() {
1193
+ const page = this._browserBackend.context?.currentTab()?.page;
1194
+ if (!page) return;
1195
+ const url = page.url();
1196
+ if (url.startsWith("chrome-extension://") || url.startsWith("about:") || url.startsWith("file://"))
1197
+ return;
1198
+ const alreadyEnabled = await page.evaluate(() => {
1199
+ const host = document.querySelector("flt-semantics-host");
1200
+ return host && host.children.length > 0;
1201
+ }).catch(() => false);
1202
+ if (alreadyEnabled) {
1203
+ traceDebug("Flutter accessibility already enabled");
1204
+ return;
1205
+ }
1206
+ let isFlutter = await page.evaluate(
1207
+ () => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder")
1208
+ ).catch(() => false);
1209
+ if (!isFlutter) {
1210
+ const hasCanvas = await page.evaluate(() => !!document.querySelector("canvas")).catch(() => false);
1211
+ if (hasCanvas) {
1212
+ isFlutter = await page.waitForFunction(
1213
+ () => !!document.querySelector("flt-glass-pane, flutter-view, flt-semantics-placeholder"),
1214
+ { timeout: 5e3 }
1215
+ ).then(() => true).catch(() => false);
1216
+ }
1217
+ }
1218
+ if (!isFlutter) return;
1219
+ const placeholder = page.locator("flt-semantics-placeholder").first();
1220
+ const clicked = await placeholder.click({ force: true }).then(() => true).catch(() => false);
1221
+ if (!clicked) {
1222
+ traceDebug("Flutter detected but placeholder click failed");
1223
+ return;
1224
+ }
1225
+ const enabled = await page.waitForFunction(() => {
1226
+ const host = document.querySelector("flt-semantics-host");
1227
+ return host && host.children.length > 0;
1228
+ }, { timeout: 5e3 }).then(() => true).catch(() => false);
1229
+ if (enabled)
1230
+ traceDebug("Flutter accessibility mode enabled");
1231
+ else
1232
+ traceDebug("Flutter placeholder clicked but semantics host did not appear");
1233
+ }
812
1234
  /** Clean up temp directory and optionally recreate it for next session. */
813
1235
  _cleanupTempDir(recreate = false) {
814
1236
  try {
@@ -838,6 +1260,51 @@ ${details}` }]
838
1260
  this._cleanupTempDir(false);
839
1261
  }
840
1262
  }
1263
+ function pageEvaluateStripWrapperRoles(target) {
1264
+ const TOKEN = "__skyr_3728_stripped__";
1265
+ const w = window;
1266
+ if (w[TOKEN])
1267
+ return false;
1268
+ if (!target || !(target instanceof Element))
1269
+ return false;
1270
+ const labelSelector = 'input, textarea, select, label, [role="checkbox"], [role="radio"], [role="switch"], [role="textbox"], [role="combobox"]';
1271
+ let wrapper = null;
1272
+ for (let el = target; el; el = el.parentElement) {
1273
+ const role = el.getAttribute && el.getAttribute("role");
1274
+ if (role !== "button" && role !== "link")
1275
+ continue;
1276
+ if (el.children.length === 0)
1277
+ continue;
1278
+ if (!el.querySelector(labelSelector))
1279
+ continue;
1280
+ wrapper = el;
1281
+ break;
1282
+ }
1283
+ if (!wrapper)
1284
+ return false;
1285
+ const stripped = [];
1286
+ document.querySelectorAll('[role="button"], [role="link"]').forEach((el) => {
1287
+ if (el.children.length === 0)
1288
+ return;
1289
+ const role = el.getAttribute("role");
1290
+ stripped.push({ el, role });
1291
+ el.removeAttribute("role");
1292
+ });
1293
+ w[TOKEN] = stripped;
1294
+ return true;
1295
+ }
1296
+ function pageEvaluateRestoreWrapperRoles() {
1297
+ const TOKEN = "__skyr_3728_stripped__";
1298
+ const w = window;
1299
+ const stripped = w[TOKEN];
1300
+ if (!stripped)
1301
+ return;
1302
+ for (const { el, role } of stripped) {
1303
+ if (el.isConnected)
1304
+ el.setAttribute("role", role);
1305
+ }
1306
+ delete w[TOKEN];
1307
+ }
841
1308
  function splitExtensionPaths(value) {
842
1309
  if (!value)
843
1310
  return void 0;