@skyramp/mcp 0.4.2 → 0.4.3-rc.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 (135) hide show
  1. package/build/execution/wrapperConfig.d.ts +4 -0
  2. package/build/execution/wrapperConfig.js +14 -2
  3. package/build/prompts/code-reuse.d.ts +5 -1
  4. package/build/prompts/code-reuse.js +16 -10
  5. package/build/prompts/modularization/ui-test-modularization.js +9 -2
  6. package/build/prompts/pom-aware-code-reuse.d.ts +4 -1
  7. package/build/prompts/pom-aware-code-reuse.js +38 -6
  8. package/build/prompts/reuse-hand-off.d.ts +4 -0
  9. package/build/prompts/reuse-hand-off.js +10 -5
  10. package/build/prompts/shared-helper-policy.d.ts +5 -0
  11. package/build/prompts/shared-helper-policy.js +5 -0
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +2 -1
  14. package/build/prompts/testbot/testbot-prompts.js +33 -16
  15. package/build/recommendation/answers.d.ts +5 -3
  16. package/build/recommendation/answers.js +5 -2
  17. package/build/recommendation/fakeVerifyContext.d.ts +3 -0
  18. package/build/recommendation/fakeVerifyContext.js +16 -0
  19. package/build/recommendation/runVerifiers.js +2 -0
  20. package/build/recommendation/types.d.ts +12 -0
  21. package/build/recommendation/verifierContracts.d.ts +32 -2
  22. package/build/recommendation/verifierContracts.js +45 -4
  23. package/build/recommendation/verifiers/changedLinesClaimed.d.ts +17 -0
  24. package/build/recommendation/verifiers/changedLinesClaimed.js +88 -0
  25. package/build/recommendation/verifiers/coverage.js +143 -4
  26. package/build/recommendation/verifiers/expectedValueSourced.js +8 -0
  27. package/build/recommendation/verifiers/requirementSourced.js +141 -11
  28. package/build/services/TestGenerationService.js +4 -0
  29. package/build/tools/code-refactor/caller-gate.d.ts +105 -0
  30. package/build/tools/code-refactor/caller-gate.js +174 -0
  31. package/build/tools/code-refactor/changed-helper-report.d.ts +62 -0
  32. package/build/tools/code-refactor/changed-helper-report.js +114 -0
  33. package/build/tools/code-refactor/codeReuseTool.js +131 -62
  34. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -2
  35. package/build/tools/code-refactor/helper-callers.d.ts +65 -0
  36. package/build/tools/code-refactor/helper-callers.js +85 -0
  37. package/build/tools/code-refactor/modularizationTool.js +10 -0
  38. package/build/tools/code-refactor/pom-pass-state.d.ts +51 -0
  39. package/build/tools/code-refactor/pom-pass-state.js +115 -0
  40. package/build/tools/code-refactor/removed-modules.d.ts +33 -0
  41. package/build/tools/code-refactor/removed-modules.js +61 -0
  42. package/build/tools/code-refactor/retrofit-state.d.ts +3 -2
  43. package/build/tools/code-refactor/retrofit-state.js +11 -12
  44. package/build/tools/code-refactor/reuse-outcome.d.ts +33 -1
  45. package/build/tools/code-refactor/reuse-record-store.d.ts +57 -0
  46. package/build/tools/code-refactor/reuse-record-store.js +140 -0
  47. package/build/tools/code-refactor/reuse-state.d.ts +34 -9
  48. package/build/tools/code-refactor/reuse-state.js +140 -135
  49. package/build/tools/code-refactor/utils-verify-gates.js +42 -18
  50. package/build/tools/code-refactor/verify-gates.d.ts +4 -0
  51. package/build/tools/code-refactor/verify-gates.js +8 -1
  52. package/build/tools/generate-tests/generateContractRestTool.js +7 -7
  53. package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -8
  54. package/build/tools/generate-tests/generateUIRestTool.js +3 -3
  55. package/build/tools/submitReportTool.js +121 -30
  56. package/build/tools/test-management/actionsTool.js +7 -0
  57. package/build/tools/test-management/registerTestPlanTool.d.ts +1 -0
  58. package/build/tools/test-management/registerTestPlanTool.js +71 -23
  59. package/build/types/RepositoryAnalysis.d.ts +20 -20
  60. package/build/types/ReuseOutcome.d.ts +123 -0
  61. package/build/types/TestExecution.d.ts +11 -0
  62. package/build/types/TestExecution.js +19 -0
  63. package/build/types/index.d.ts +1 -1
  64. package/build/utils/branchDiff.d.ts +1 -1
  65. package/build/utils/branchDiff.js +1 -1
  66. package/build/utils/changedRuns.d.ts +13 -0
  67. package/build/utils/changedRuns.js +56 -0
  68. package/build/utils/featureFlags.d.ts +8 -10
  69. package/build/utils/featureFlags.js +23 -21
  70. package/build/utils/normalizeSkyrampImports.d.ts +4 -1
  71. package/build/utils/normalizeSkyrampImports.js +24 -17
  72. package/build/utils/pom-scope/ownership.d.ts +19 -0
  73. package/build/utils/pom-scope/ownership.js +31 -0
  74. package/build/utils/pom-scope/pom-files.d.ts +8 -0
  75. package/build/utils/pom-scope/pom-files.js +8 -0
  76. package/build/utils/pom-scope/scoring.js +13 -1
  77. package/build/utils/pom-scope/strip.d.ts +10 -0
  78. package/build/utils/pom-scope/strip.js +9 -0
  79. package/build/utils/pom-verify/verify.js +13 -2
  80. package/build/utils/reuseRouting.d.ts +30 -2
  81. package/build/utils/reuseRouting.js +36 -15
  82. package/build/utils/testDependencyPolicy.js +4 -16
  83. package/build/utils/utils-verify/action-sites.d.ts +2 -2
  84. package/build/utils/utils-verify/allow.d.ts +1 -1
  85. package/build/utils/utils-verify/allow.js +15 -0
  86. package/build/utils/utils-verify/body-reach.d.ts +2 -2
  87. package/build/utils/utils-verify/body-reach.js +4 -1
  88. package/build/utils/utils-verify/call-sites.d.ts +16 -6
  89. package/build/utils/utils-verify/call-sites.js +12 -6
  90. package/build/utils/utils-verify/changed-helpers.d.ts +38 -0
  91. package/build/utils/utils-verify/changed-helpers.js +91 -0
  92. package/build/utils/utils-verify/changed-lines.d.ts +48 -0
  93. package/build/utils/utils-verify/changed-lines.js +106 -0
  94. package/build/utils/utils-verify/head.d.ts +73 -0
  95. package/build/utils/utils-verify/head.js +156 -0
  96. package/build/utils/utils-verify/importers.d.ts +59 -10
  97. package/build/utils/utils-verify/importers.js +121 -31
  98. package/build/utils/utils-verify/in-house.js +26 -31
  99. package/build/utils/utils-verify/index.d.ts +3 -0
  100. package/build/utils/utils-verify/index.js +3 -0
  101. package/build/utils/utils-verify/language-spec.js +10 -2
  102. package/build/utils/utils-verify/locate.d.ts +3 -0
  103. package/build/utils/utils-verify/locate.js +6 -6
  104. package/build/utils/utils-verify/parse.d.ts +13 -1
  105. package/build/utils/utils-verify/parse.js +122 -49
  106. package/build/utils/utils-verify/retrofit-equivalence.d.ts +0 -5
  107. package/build/utils/utils-verify/retrofit-equivalence.js +2 -34
  108. package/build/utils/utils-verify/stage.js +4 -16
  109. package/build/utils/utils-verify/typecheck.d.ts +25 -4
  110. package/build/utils/utils-verify/typecheck.js +95 -13
  111. package/build/utils/utils-verify/verify.d.ts +33 -8
  112. package/build/utils/utils-verify/verify.js +180 -18
  113. package/build/utils/versions.d.ts +3 -3
  114. package/build/utils/versions.js +1 -1
  115. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1 -0
  116. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +1 -0
  117. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +6 -0
  118. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +4 -2
  119. package/node_modules/playwright/lib/mcp/skyramp/snapshotLocators.js +246 -0
  120. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +378 -237
  121. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +21 -0
  122. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  123. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-LNgEKtdV.js → codeMirrorModule-Bou8kKzE.js} +1 -1
  124. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-Bwr1eMKC.js → defaultSettingsView-DTp2-8Si.js} +3 -3
  125. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  126. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.BAkLd5DX.js → index.uiPTFncY.js} +1 -1
  127. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.BPopbasy.js → uiMode.GLqsx5cI.js} +1 -1
  128. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  129. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  130. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  131. package/node_modules/playwright/package.json +1 -1
  132. package/package.json +3 -3
  133. package/plugin/prompts/code-reuse/hand-off.md +13 -1
  134. package/plugin/prompts/plan-tests.md +2 -2
  135. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -0
@@ -55,6 +55,7 @@ var import_visualSnapshotTool = require("./visualSnapshotTool");
55
55
  var import_gojsTool = require("./gojsTool");
56
56
  var import_cssValue = require("./common/cssValue");
57
57
  var import_visualSnapshot = require("./common/visualSnapshot");
58
+ var import_snapshotLocators = require("./snapshotLocators");
58
59
  var import_tableAssertTool = require("./tableAssertTool");
59
60
  var import_tableSelectorBuilder = require("./tableSelectorBuilder");
60
61
  var import_types = require("./types");
@@ -82,11 +83,14 @@ function dropDynamicParentChain(chain) {
82
83
  return m && (0, import_dynamicId.isDynamicId)(m[1]) ? null : chain;
83
84
  }
84
85
  const LIVE_READ_TIMEOUT_MS = 5e3;
86
+ const REF_RESOLVE_TIMEOUT_MS = 1e3;
85
87
  const MAX_ANCESTOR_HOVERS = 8;
86
88
  const ANCESTOR_WALK_BUDGET_MS = 1e4;
87
89
  class TraceRecordingBackend {
88
90
  // per-type baseline filename counter
89
91
  constructor(options) {
92
+ // The attribute getByTestId() resolves against (configured or the default).
93
+ this._testIdAttribute = "data-testid";
90
94
  this._trackedActions = [];
91
95
  this._droppedActions = [];
92
96
  this._initialized = false;
@@ -120,6 +124,7 @@ class TraceRecordingBackend {
120
124
  ] : void 0;
121
125
  const headless = loadExtension?.length ? false : this._options.headless ?? (!!process.env.CI || import_os.default.platform() === "linux" && !process.env.DISPLAY);
122
126
  const testIdAttribute = process.env.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE?.trim() || this._options.testIdAttribute?.trim();
127
+ this._testIdAttribute = testIdAttribute || "data-testid";
123
128
  const config = await (0, import_config.resolveConfig)({
124
129
  ...testIdAttribute ? { testIdAttribute } : {},
125
130
  browser: {
@@ -490,10 +495,9 @@ Reloaded current page: ${currentUrl}
490
495
  if (!params.ref)
491
496
  return { content: [{ type: "text", text: '### Error\nsnapshotType "element" requires a ref (from the latest browser_snapshot).' }], isError: true };
492
497
  const resolved = await this._resolveRefToLocator(params.ref, params.element ?? "");
493
- if (!resolved)
494
- return { content: [{ type: "text", text: `### Error
495
- Could not resolve a durable selector for ref=${params.ref}. Take a fresh browser_snapshot and retry, or use snapshotType "region".` }], isError: true };
496
- input.selector = resolved.selector;
498
+ if ("error" in resolved)
499
+ return resolved.error;
500
+ input.selector = resolved.parsed.selector;
497
501
  }
498
502
  if (params.snapshotType === "gojsDiagram") {
499
503
  const diagramSelector = params.diagramSelector ?? "#divCenter";
@@ -531,33 +535,68 @@ Baseline "${input.filename}" (${params.snapshotType})${targetDesc} recorded; the
531
535
  return (0, import_visualSnapshot.nextSnapshotFilename)(type, this._visualSnapshotCounters[type] + 1);
532
536
  }
533
537
  /**
534
- * Resolve a snapshot ref to a durable Playwright selector, mirroring the
535
- * element-resolution path of _handleAssert: hover to get the resolved code,
536
- * parse it to a selector, and prefer a snapshot-accessible-name selector over
537
- * a brittle raw-CSS id (the Flutter-durable fallback). Returns null if the ref
538
- * can't be resolved.
538
+ * The shared front of browser_assert and element visual snapshots: hover the
539
+ * ref for the generator's selector, parse it, take the snapshot, and prefer
540
+ * the snapshot's accessible name when the hover gave CSS (the Flutter-durable
541
+ * fallback). Hover records nothing and changes no data, but it does move the
542
+ * pointer: :hover styles and mouseenter handlers fire (the live css/class/
543
+ * attribute reads move it off again). `error` is the message body; each caller
544
+ * adds its own heading.
539
545
  */
540
- async _resolveRefToLocator(ref, element) {
546
+ async _resolveRef(ref, element) {
541
547
  const hoverResult = await this._browserBackend.callTool("browser_hover", { element, ref });
542
- if (hoverResult.isError)
543
- return null;
544
- const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
545
- const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
546
- if (!locatorMatch)
547
- return null;
548
- const locatorExpr = locatorMatch[1].trim();
549
- let parsed = this._codeToLocator(locatorExpr);
550
- if (!parsed || parsed.locator.kind === "css") {
551
- const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
552
- if (!snapResult.isError) {
553
- const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
554
- const refLine = snapText.split("\n").find((l) => l.includes(`[ref=${ref}]`)) || "";
555
- const fromSnapshot = this._extractLocatorForRef(refLine);
556
- if (fromSnapshot)
557
- parsed = fromSnapshot;
558
- }
548
+ let locatorExpr;
549
+ if (hoverResult.isError) {
550
+ const errText = hoverResult.content?.[0]?.type === "text" ? hoverResult.content[0].text : "";
551
+ const tab = /intercepts pointer events/.test(errText) ? this._browserBackend.context?.currentTab() : void 0;
552
+ let tabError = "";
553
+ const viaTab = tab ? await tab.refLocator({ ref, element }).then((r) => r.resolved, (e) => {
554
+ tabError = String(e?.message ?? e).split("\n")[0];
555
+ traceDebug(`Ref ${ref} could not be resolved without hovering either: ${tabError}`);
556
+ return void 0;
557
+ }) : void 0;
558
+ if (!viaTab)
559
+ return { error: `Could not resolve element ref=${ref}. ${errText}${tabError ? ` Resolving it without hovering failed too: ${tabError}` : ""}` };
560
+ traceDebug(`Hover on ref ${ref} is intercepted; resolved its locator without hovering: ${viaTab}`);
561
+ locatorExpr = viaTab;
562
+ } else {
563
+ const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
564
+ const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
565
+ if (!locatorMatch)
566
+ return { error: "Could not extract selector from hover result." };
567
+ locatorExpr = locatorMatch[1].trim();
559
568
  }
560
- return parsed;
569
+ const fromHover = this._codeToLocator(locatorExpr);
570
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
571
+ if (snapResult.isError) {
572
+ const errText = snapResult.content?.[0]?.type === "text" ? snapResult.content[0].text : "";
573
+ return { error: `Could not capture page snapshot to verify ref=${ref}. ${errText}` };
574
+ }
575
+ const snapLines = (snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "").split("\n");
576
+ const refIdx = snapLines.findIndex((l) => l.includes(`[ref=${ref}]`));
577
+ const refLine = refIdx >= 0 ? snapLines[refIdx] : "";
578
+ const parsed = this._preferSnapshotLocator(fromHover, refLine);
579
+ if (parsed !== fromHover)
580
+ traceDebug(`Resolved selector from snapshot accessible name (hover code was ${fromHover ? "CSS" : "unparseable"}: ${locatorExpr})`);
581
+ if (!parsed)
582
+ return { error: `Could not parse locator: ${locatorExpr}` };
583
+ return { parsed, snapLines, refIdx, refLine };
584
+ }
585
+ /**
586
+ * The selector an element visual snapshot records for a ref: _resolveRef,
587
+ * then made to pick out the ref's own element as an assertion's is (the
588
+ * screenshot locator is strict at replay too) — a name repeated across a list
589
+ * is pinned, and one the live page says is another element is refused.
590
+ */
591
+ async _resolveRefToLocator(ref, element) {
592
+ const resolved = await this._resolveRef(ref, element);
593
+ if ("error" in resolved)
594
+ return { error: { content: [{ type: "text", text: `### Error
595
+ ${resolved.error} Take a fresh browser_snapshot and retry, or use snapshotType "region".` }], isError: true } };
596
+ const unique = await this._makeUnique(resolved.parsed, { snapLines: resolved.snapLines, ref, element, lineText: (0, import_snapshotLocators.snapshotLineText)(resolved.refLine) ?? "", exact: false });
597
+ if (unique.refusal)
598
+ return { error: unique.refusal };
599
+ return { parsed: unique.parsed };
561
600
  }
562
601
  /**
563
602
  * Load a prior Skyramp trace and replay it against the live browser, honoring
@@ -1322,47 +1361,23 @@ For "${params.type}" assertions, "property" (the ${params.type === "css" ? "CSS
1322
1361
  if ((params.type === "checked" || params.type === "disabled") && params.expected !== void 0 && params.expected !== "true" && params.expected !== "false")
1323
1362
  return { content: [{ type: "text", text: `### Error
1324
1363
  For "${params.type}" assertions, "expected" must be "true" or "false".` }], isError: true };
1325
- const hoverResult = await this._browserBackend.callTool("browser_hover", {
1326
- element: params.element,
1327
- ref: params.ref
1328
- });
1329
- if (hoverResult.isError) {
1330
- const errText = hoverResult.content?.[0]?.type === "text" ? hoverResult.content[0].text : "";
1331
- return { content: [{ type: "text", text: `### Assertion Failed
1332
- Could not resolve element ref=${params.ref}. ${errText}` }], isError: true };
1333
- }
1334
- const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
1335
- const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
1336
- if (!locatorMatch) {
1337
- return { content: [{ type: "text", text: `### Assertion Failed
1338
- Could not extract selector from hover result.` }], isError: true };
1339
- }
1340
- const locatorExpr = locatorMatch[1].trim();
1341
- let parsed = this._codeToLocator(locatorExpr);
1342
- const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
1343
- if (snapResult.isError) {
1344
- const errText = snapResult.content?.[0]?.type === "text" ? snapResult.content[0].text : "";
1345
- return { content: [{ type: "text", text: `### Assertion Failed
1346
- Could not capture page snapshot to verify the assertion. ${errText}` }], isError: true };
1347
- }
1348
- const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
1349
- const snapLines = snapText.split("\n");
1350
- const refLine = snapLines.find((l) => l.includes(`[ref=${params.ref}]`)) || "";
1351
- if (!parsed || parsed.locator.kind === "css") {
1352
- const fromSnapshot = this._extractLocatorForRef(refLine);
1353
- if (fromSnapshot) {
1354
- traceDebug(`Resolved assert selector from snapshot accessible name (hover code was ${parsed ? "brittle CSS" : "unparseable"}: ${locatorExpr})`);
1355
- parsed = fromSnapshot;
1356
- }
1357
- }
1358
- if (!parsed) {
1364
+ const resolved = await this._resolveRef(params.ref, params.element);
1365
+ if ("error" in resolved)
1359
1366
  return { content: [{ type: "text", text: `### Assertion Failed
1360
- Could not parse locator: ${locatorExpr}` }], isError: true };
1361
- }
1362
- const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
1363
- const elementText = textMatch?.[1] || "";
1364
- const valueMatch = refLine.match(/\]:\s*(.+)$/);
1365
- const elementValue = valueMatch?.[1]?.trim() || "";
1367
+ ${resolved.error}` }], isError: true };
1368
+ let parsed = resolved.parsed;
1369
+ const { snapLines, refIdx, refLine } = resolved;
1370
+ const inputLine = (0, import_snapshotLocators.isInputLine)(refLine);
1371
+ const quotedName = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/)?.[1] ?? "";
1372
+ const lineText = (0, import_snapshotLocators.snapshotLineText)(refLine) ?? "";
1373
+ let elementText = inputLine ? quotedName : lineText;
1374
+ if (params.type === "text" && !inputLine) {
1375
+ const live = await this._liveText(params.ref);
1376
+ if (live !== void 0)
1377
+ elementText = live;
1378
+ }
1379
+ const readsValue = params.type === "value" || params.type === "text" && inputLine;
1380
+ const elementValue = readsValue ? await this._inputValue(snapLines, refIdx, params.ref) : "";
1366
1381
  let passed = false;
1367
1382
  let details = "";
1368
1383
  let countSelector = "";
@@ -1375,13 +1390,10 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
1375
1390
  const notReady = await this._requireLoadedPage(tab, "domcontentloaded");
1376
1391
  if (notReady)
1377
1392
  return notReady;
1378
- if (parsed.locator.kind === "text") {
1379
- const improved = await this._improveTextSelector(snapLines, params.ref, parsed);
1380
- if (improved) {
1381
- traceDebug(`Improved ambiguous text selector before live read: ${improved.selector}`);
1382
- parsed = improved;
1383
- }
1384
- }
1393
+ const unique = await this._makeUnique(parsed, { snapLines, ref: params.ref, element: params.element, lineText, exact: false });
1394
+ if (unique.refusal)
1395
+ return unique.refusal;
1396
+ parsed = unique.parsed;
1385
1397
  const live = tab.page.locator(parsed.selector);
1386
1398
  const kind = params.type;
1387
1399
  const name = kind === "class" ? "" : params.property.trim();
@@ -1440,6 +1452,18 @@ Could not read the un-hovered state of "${params.element}": the pointer could no
1440
1452
  const classes = (reading.value ?? "").split(" ").filter(Boolean);
1441
1453
  passed = classes.includes(className);
1442
1454
  details = passed ? `Class assertion passed: "${params.element}" carries class "${className}".` : `Class assertion FAILED: "${params.element}" has classes [${classes.join(" ")}], expected "${className}".`;
1455
+ if (!passed) {
1456
+ const depth = await live.evaluate((el, cls) => {
1457
+ let up = el.parentElement;
1458
+ for (let d = 1; up && d <= 3; d++, up = up.parentElement) {
1459
+ if (up.classList.contains(cls))
1460
+ return d;
1461
+ }
1462
+ return 0;
1463
+ }, className, { timeout: LIVE_READ_TIMEOUT_MS }).catch(() => 0);
1464
+ if (depth)
1465
+ details += ` "${className}" is on its ${depth === 1 ? "parent" : `ancestor ${depth} levels up`}: assert it on that element \u2014 with browser_assert on its ref if the snapshot has one, otherwise in the generated test.`;
1466
+ }
1443
1467
  } else {
1444
1468
  const actual = reading.value;
1445
1469
  passed = actual !== null && actual === params.expected;
@@ -1450,16 +1474,18 @@ Could not read the un-hovered state of "${params.element}": the pointer could no
1450
1474
  const msg = String(e?.message ?? e);
1451
1475
  if (/strict mode violation/i.test(msg))
1452
1476
  return { content: [{ type: "text", text: `### Error
1453
- Target ${parsed.selector} matches multiple elements, so the ${kind} read is ambiguous. Use a more specific target (a data-testid, or role + exact name). ${msg.split("\n")[0]}` }], isError: true };
1477
+ Target ${parsed.selector} matches multiple elements, so the ${kind} read is ambiguous${unique.pinFailure ? `, and ${unique.pinFailure}` : ""}. Use a more specific target (a data-testid, or role + exact name). ${msg.split("\n")[0]}` }], isError: true };
1454
1478
  if (e?.name === "TimeoutError")
1455
1479
  return { content: [{ type: "text", text: `### Error
1456
1480
  "${params.element}" (${parsed.selector}) did not resolve within ${LIVE_READ_TIMEOUT_MS} ms. Re-snapshot and retry with a fresh ref.` }], isError: true };
1457
1481
  return { content: [{ type: "text", text: `### Error
1458
1482
  Could not read ${kind} of "${params.element}": ${msg}` }], isError: true };
1459
1483
  }
1484
+ if (passed)
1485
+ details += unique.note;
1460
1486
  } else if (params.type === "text") {
1461
- passed = elementText.includes(params.expected) || elementValue.includes(params.expected);
1462
- details = passed ? `Text assertion passed: "${params.element}" contains "${params.expected}".` : `Text assertion FAILED: "${params.element}" has text "${elementText}"${elementValue ? ` / value "${elementValue}"` : ""}, expected "${params.expected}".`;
1487
+ passed = elementText.includes(params.expected);
1488
+ details = passed ? `Text assertion passed: "${params.element}" contains "${params.expected}".` : `Text assertion FAILED: "${params.element}" has text "${elementText}", expected "${params.expected}".` + (inputLine && elementValue.includes(params.expected) ? ` It is an input whose value contains it: assert what it holds with type: "value".` : "");
1463
1489
  } else if (params.type === "value") {
1464
1490
  passed = elementValue === params.expected || elementValue.includes(params.expected);
1465
1491
  details = passed ? `Value assertion passed: "${params.element}" has value "${params.expected}".` : `Value assertion FAILED: "${params.element}" has value "${elementValue}", expected "${params.expected}".`;
@@ -1512,77 +1538,86 @@ Could not count "${refRole}" elements: ${e.message}` }], isError: true };
1512
1538
  }
1513
1539
  if (passed) {
1514
1540
  const recordedExpected = params.type === "text" && params.substring !== false ? (0, import_utils.stableTextLead)(params.expected) : params.expected;
1515
- const selectorText = parsed.locator.kind === "text" ? parsed.locator.body : parsed.locator.next?.kind === "text" ? parsed.locator.next.body : parsed.locator.options?.name ?? null;
1516
- const tautological = Boolean((params.type === "text" || params.type === "value") && selectorText && selectorText === recordedExpected);
1517
- if (tautological) {
1518
- traceDebug(`Skipped tautological assertion: selector already matches "${recordedExpected}"`);
1519
- } else {
1520
- if (parsed.locator.kind === "text" && params.type !== "count" && params.type !== "css" && params.type !== "class" && params.type !== "attribute") {
1521
- const improved = await this._improveTextSelector(snapLines, params.ref, parsed);
1522
- if (improved) {
1523
- traceDebug(`Improved ambiguous text selector to: ${improved.selector}`);
1524
- parsed = improved;
1541
+ const tail = (0, import_snapshotLocators.leaf)(parsed.locator);
1542
+ const selectorText = tail.kind === "text" ? tail.body : tail.kind === "role" ? tail.options?.name ?? null : null;
1543
+ const tautological = Boolean(params.type === "text" && selectorText && selectorText === recordedExpected);
1544
+ const recordType = tautological ? "visible" : params.type;
1545
+ if (recordType !== params.type) {
1546
+ traceDebug(`Tautological text assertion recorded as presence: selector already matches "${recordedExpected}"`);
1547
+ details += ` Recorded as a presence check (toBeVisible): the locator already selects by "${recordedExpected}".`;
1548
+ }
1549
+ if (params.type !== "count" && params.type !== "css" && params.type !== "class" && params.type !== "attribute") {
1550
+ const unique = await this._makeUnique(parsed, { snapLines, ref: params.ref, element: params.element, lineText, exact: recordType === "visible" });
1551
+ if (unique.refusal)
1552
+ return unique.refusal;
1553
+ parsed = unique.parsed;
1554
+ details += unique.note;
1555
+ if (recordType === "visible" && unique.verdict === "unverified") {
1556
+ const matches = (0, import_snapshotLocators.snapshotMatches)(snapLines, parsed.locator);
1557
+ if (matches !== void 0 && matches > 1) {
1558
+ return { content: [{ type: "text", text: `### Error
1559
+ "${params.element}" is present, but ${parsed.selector} matches ${matches} elements on the page, so a presence check on it would be ambiguous at replay${unique.pinFailure ? `, and ${unique.pinFailure}` : ""}. Nothing was recorded. Assert it through a test id or a role with its exact name, or write the check in the generated test.` }], isError: true };
1525
1560
  }
1526
1561
  }
1527
- let assertName;
1528
- let args;
1529
- let code;
1530
- switch (params.type) {
1531
- case "checked": {
1532
- const expectChecked = params.expected !== "false";
1533
- assertName = "assertChecked";
1534
- args = { type: "checked", ref: params.ref, selector: parsed.selector, checked: expectChecked };
1535
- code = `assertChecked:${parsed.selector}:${expectChecked}`;
1536
- break;
1537
- }
1538
- case "disabled": {
1539
- const expectDisabled = params.expected !== "false";
1540
- assertName = "assertDisabled";
1541
- args = { type: "disabled", ref: params.ref, selector: parsed.selector, disabled: expectDisabled };
1542
- code = `assertDisabled:${parsed.selector}:${expectDisabled}`;
1543
- break;
1544
- }
1545
- case "visible":
1546
- assertName = "assertVisible";
1547
- args = { type: "visible", ref: params.ref, selector: parsed.selector };
1548
- code = `assertVisible:${parsed.selector}`;
1549
- break;
1550
- case "count":
1551
- assertName = "assertCount";
1552
- args = { type: "count", ref: params.ref, selector: countSelector, count: Number(params.expected) };
1553
- code = `assertCount:${countSelector}:${params.expected}`;
1554
- break;
1555
- case "value":
1556
- assertName = "assertValue";
1557
- args = { type: "value", ref: params.ref, selector: parsed.selector, expected: recordedExpected };
1558
- code = `assertValue:${parsed.selector}:${recordedExpected}`;
1559
- break;
1560
- case "css":
1561
- assertName = "assertCSS";
1562
- args = { type: "css", ref: params.ref, selector: parsed.selector, property: params.property.trim(), expected: liveValue ?? params.expected };
1563
- code = `assertCSS:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1564
- break;
1565
- case "class":
1566
- assertName = "assertClass";
1567
- args = { type: "class", ref: params.ref, selector: parsed.selector, expected: params.expected.trim() };
1568
- code = `assertClass:${parsed.selector}:${params.expected.trim()}`;
1569
- break;
1570
- case "attribute":
1571
- assertName = "assertAttribute";
1572
- args = { type: "attribute", ref: params.ref, selector: parsed.selector, property: params.property.trim(), expected: liveValue ?? params.expected };
1573
- code = `assertAttribute:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1574
- break;
1575
- default:
1576
- assertName = "assertText";
1577
- args = { type: "text", ref: params.ref, selector: parsed.selector, expected: recordedExpected, substring: params.substring };
1578
- code = `assertText:${parsed.selector}:${recordedExpected}:${params.substring}`;
1579
- break;
1562
+ }
1563
+ let assertName;
1564
+ let args;
1565
+ let code;
1566
+ switch (recordType) {
1567
+ case "checked": {
1568
+ const expectChecked = params.expected !== "false";
1569
+ assertName = "assertChecked";
1570
+ args = { type: "checked", ref: params.ref, selector: parsed.selector, locator: parsed.locator, checked: expectChecked };
1571
+ code = `assertChecked:${parsed.selector}:${expectChecked}`;
1572
+ break;
1573
+ }
1574
+ case "disabled": {
1575
+ const expectDisabled = params.expected !== "false";
1576
+ assertName = "assertDisabled";
1577
+ args = { type: "disabled", ref: params.ref, selector: parsed.selector, locator: parsed.locator, disabled: expectDisabled };
1578
+ code = `assertDisabled:${parsed.selector}:${expectDisabled}`;
1579
+ break;
1580
1580
  }
1581
- if (unhoverMove)
1582
- this._trackedActions.push({ toolName: "browser_mouse_action", args: { name: "mouse.move", position: unhoverMove }, code: "", timestamp: timestamp - 1, pageAlias: this._currentPageAlias });
1583
- this._trackedActions.push({ toolName: "browser_assert", args, code, timestamp, pageAlias: this._currentPageAlias });
1584
- traceDebug(`Assert: ${assertName} with selector ${args.selector}`);
1581
+ case "visible":
1582
+ assertName = "assertVisible";
1583
+ args = { type: "visible", ref: params.ref, selector: parsed.selector, locator: parsed.locator };
1584
+ code = `assertVisible:${parsed.selector}`;
1585
+ break;
1586
+ case "count":
1587
+ assertName = "assertCount";
1588
+ args = { type: "count", ref: params.ref, selector: countSelector, locator: { kind: "role", body: countSelector.slice("internal:role=".length), options: { attrs: [], exact: false } }, count: Number(params.expected) };
1589
+ code = `assertCount:${countSelector}:${params.expected}`;
1590
+ break;
1591
+ case "value":
1592
+ assertName = "assertValue";
1593
+ args = { type: "value", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: recordedExpected };
1594
+ code = `assertValue:${parsed.selector}:${recordedExpected}`;
1595
+ break;
1596
+ case "css":
1597
+ assertName = "assertCSS";
1598
+ args = { type: "css", ref: params.ref, selector: parsed.selector, locator: parsed.locator, property: params.property.trim(), expected: liveValue ?? params.expected };
1599
+ code = `assertCSS:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1600
+ break;
1601
+ case "class":
1602
+ assertName = "assertClass";
1603
+ args = { type: "class", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: params.expected.trim() };
1604
+ code = `assertClass:${parsed.selector}:${params.expected.trim()}`;
1605
+ break;
1606
+ case "attribute":
1607
+ assertName = "assertAttribute";
1608
+ args = { type: "attribute", ref: params.ref, selector: parsed.selector, locator: parsed.locator, property: params.property.trim(), expected: liveValue ?? params.expected };
1609
+ code = `assertAttribute:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1610
+ break;
1611
+ default:
1612
+ assertName = "assertText";
1613
+ args = { type: "text", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: recordedExpected, substring: params.substring };
1614
+ code = `assertText:${parsed.selector}:${recordedExpected}:${params.substring}`;
1615
+ break;
1585
1616
  }
1617
+ if (unhoverMove)
1618
+ this._trackedActions.push({ toolName: "browser_mouse_action", args: { name: "mouse.move", position: unhoverMove }, code: "", timestamp: timestamp - 1, pageAlias: this._currentPageAlias });
1619
+ this._trackedActions.push({ toolName: "browser_assert", args, code, timestamp, pageAlias: this._currentPageAlias });
1620
+ traceDebug(`Assert: ${assertName} with selector ${args.selector}`);
1586
1621
  }
1587
1622
  return {
1588
1623
  content: [{ type: "text", text: `### ${passed ? "Assertion Passed" : "Assertion Failed"}
@@ -1768,8 +1803,8 @@ ${text}` }], isError: true });
1768
1803
  let locator;
1769
1804
  if (params.testId?.trim()) {
1770
1805
  const testId = params.testId.trim();
1771
- selector = `internal:testid=[data-testid=${JSON.stringify(testId)}s]`;
1772
- locator = { kind: "test-id", body: testId, options: {} };
1806
+ locator = { kind: "test-id", body: testId, options: this._testIdAttribute === "data-testid" ? {} : { attribute: this._testIdAttribute } };
1807
+ selector = (0, import_snapshotLocators.renderSelector)(locator);
1773
1808
  } else if (params.role?.trim()) {
1774
1809
  const role = params.role.trim();
1775
1810
  if (!TraceRecordingBackend.ASSERTABLE_ROLES.has(role))
@@ -1822,47 +1857,30 @@ ${text}` }], isError: true });
1822
1857
  ${details}` }] };
1823
1858
  }
1824
1859
  /** Convert a Playwright locator expression to a selector + locator object for JSONL. */
1860
+ /**
1861
+ * The recorder locator for a hover's generated locator code, e.g.
1862
+ * `getByRole('listitem').filter({ hasText: 'Walk the dog' }).getByLabel('Toggle Todo')`.
1863
+ * playwright-core's own parser turns the code into its internal selector —
1864
+ * chains, `.filter({ hasText })`, options and string escapes included — and
1865
+ * locatorFromSelector maps each part to a kind the exporter and codegen carry.
1866
+ * Null when the code does not parse, is an aria-ref (no durable selector), or
1867
+ * has a part that cannot be carried (xpath, `has`, a frame hop, a regex): the
1868
+ * caller then falls back to the snapshot's accessible name. The old
1869
+ * per-method regexes matched anywhere in the expression, so a chain was cut
1870
+ * to its first link (`getByRole('listitem')` for a checkbox inside a row).
1871
+ */
1825
1872
  _codeToLocator(expr) {
1826
- const testidMatch = expr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
1827
- if (testidMatch) {
1828
- return {
1829
- selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
1830
- locator: { kind: "test-id", body: testidMatch[1], options: {} }
1831
- };
1832
- }
1833
- const roleMatch = expr.match(/getByRole\(\s*['"]([^'"]+)['"](?:\s*,\s*\{[^}]*name:\s*['"]([^'"]+)['"][^}]*\})?\s*\)/);
1834
- if (roleMatch) {
1835
- return {
1836
- selector: roleMatch[2] ? `internal:role=${roleMatch[1]}[name="${roleMatch[2]}"i]` : `internal:role=${roleMatch[1]}`,
1837
- locator: { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, ...roleMatch[2] ? { name: roleMatch[2] } : {} } }
1838
- };
1839
- }
1840
- const labelMatch = expr.match(/getByLabel\(\s*['"]([^'"]+)['"]\s*\)/);
1841
- if (labelMatch) {
1842
- return {
1843
- selector: `internal:label="${labelMatch[1]}"i`,
1844
- locator: { kind: "label", body: labelMatch[1], options: { exact: false } }
1845
- };
1846
- }
1847
- const textMatch = expr.match(/getByText\(\s*['"]([^'"]+)['"]\s*\)/);
1848
- if (textMatch) {
1849
- return {
1850
- selector: `internal:text="${textMatch[1]}"i`,
1851
- locator: { kind: "text", body: textMatch[1], options: { exact: false } }
1852
- };
1853
- }
1854
- const ariaRefMatch = expr.match(/locator\(\s*['"]aria-ref=(\w+)['"]\s*\)/);
1855
- if (ariaRefMatch) {
1873
+ const selector = (0, import_utils.locatorOrSelectorAsSelector)("javascript", expr, this._testIdAttribute);
1874
+ if (!selector) {
1875
+ traceDebug(`Hover code did not parse as a locator (or did not round-trip): ${expr}`);
1856
1876
  return null;
1857
1877
  }
1858
- const cssMatch = expr.match(/locator\(\s*['"]([#.][^'"]+)['"]\s*\)/);
1859
- if (cssMatch) {
1860
- return {
1861
- selector: cssMatch[1],
1862
- locator: { kind: "css", body: cssMatch[1], options: {} }
1863
- };
1864
- }
1865
- return null;
1878
+ if (selector.startsWith("aria-ref="))
1879
+ return null;
1880
+ const locator = (0, import_snapshotLocators.locatorFromSelector)(selector);
1881
+ if (!locator)
1882
+ traceDebug(`Hover code has a part the trace format cannot carry: ${expr} \u2192 ${selector}`);
1883
+ return locator ? { selector: (0, import_snapshotLocators.renderSelector)(locator), locator } : null;
1866
1884
  }
1867
1885
  /**
1868
1886
  * When hover resolves to a text-based locator (e.g. getByText("$849.98")),
@@ -1872,10 +1890,7 @@ ${details}` }] };
1872
1890
  */
1873
1891
  async _improveTextSelector(snapLines, ref, original) {
1874
1892
  const textBody = original.locator.body;
1875
- const occurrences = snapLines.filter((l) => {
1876
- const lineText = TraceRecordingBackend._snapshotLineText(l);
1877
- return lineText !== null && lineText.includes(textBody);
1878
- }).length;
1893
+ const occurrences = (0, import_snapshotLocators.snapshotMatches)(snapLines, original.locator) ?? 0;
1879
1894
  if (occurrences <= 1)
1880
1895
  return null;
1881
1896
  traceDebug(`Text "${textBody}" appears ${occurrences} times in snapshot \u2014 looking for parent test-id`);
@@ -1913,18 +1928,17 @@ ${details}` }] };
1913
1928
  const ancestorExpr = ancestorLocatorMatch[1].trim();
1914
1929
  const testidMatch = ancestorExpr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
1915
1930
  if (testidMatch) {
1916
- const testid = testidMatch[1];
1917
- const chainedSelector = `internal:testid=[data-testid="${testid}"s] >> internal:text="${textBody}"i`;
1918
- traceDebug(`Improved to chained selector: ${chainedSelector}`);
1919
- return {
1920
- selector: chainedSelector,
1921
- locator: {
1922
- kind: "test-id",
1923
- body: testid,
1924
- options: {},
1925
- next: { kind: "text", body: textBody, options: { exact: false } }
1926
- }
1931
+ const locator = {
1932
+ kind: "test-id",
1933
+ body: testidMatch[1],
1934
+ // Under a configured testIdAttribute (data-cy) the rendered pin
1935
+ // selector must use it, or the live pin finds nothing.
1936
+ options: this._testIdAttribute === "data-testid" ? {} : { attribute: this._testIdAttribute },
1937
+ next: { kind: "text", body: textBody, options: { exact: !!original.locator.options?.exact } }
1927
1938
  };
1939
+ const chainedSelector = (0, import_snapshotLocators.renderSelector)(locator);
1940
+ traceDebug(`Improved to chained selector: ${chainedSelector}`);
1941
+ return { selector: chainedSelector, locator };
1928
1942
  }
1929
1943
  }
1930
1944
  }
@@ -1935,18 +1949,160 @@ ${details}` }] };
1935
1949
  }
1936
1950
  return null;
1937
1951
  }
1952
+ /** The ref's value as Playwright's inputValue() reads it (which retargets a
1953
+ * `<label>` ref to its control), or undefined on any failure: not an
1954
+ * input/textarea/select, a ref that does not resolve within
1955
+ * REF_RESOLVE_TIMEOUT_MS, a closed page. The reason goes to the trace debug log. */
1956
+ /** What an input holds, as toHaveValue compares at replay: read live from the
1957
+ * element, and from the snapshot (inline, or the child line under a
1958
+ * placeholder) when the live read is not available. */
1959
+ async _inputValue(snapLines, refIdx, ref) {
1960
+ const page = this._browserBackend.context?.currentTab()?.page;
1961
+ const live = page ? await TraceRecordingBackend._liveInputValue(page, ref) : void 0;
1962
+ return live ?? (0, import_snapshotLocators.snapshotInputValue)(snapLines, refIdx);
1963
+ }
1964
+ /** The ref's rendered text as toContainText compares it — Playwright's
1965
+ * injected elementText: a submit/button input's value, shadow-root text
1966
+ * included, script/style/noscript and <head> skipped; whitespace normalized —
1967
+ * or undefined when the page or ref is unavailable. */
1968
+ async _liveText(ref) {
1969
+ const page = this._browserBackend.context?.currentTab()?.page;
1970
+ if (!page)
1971
+ return void 0;
1972
+ try {
1973
+ const text = await page.locator(`aria-ref=${ref}`).evaluate((root) => {
1974
+ const skip = (n) => n.nodeName === "SCRIPT" || n.nodeName === "NOSCRIPT" || n.nodeName === "STYLE" || !!(n.ownerDocument?.head && n.ownerDocument.head.contains(n));
1975
+ const read = (n) => {
1976
+ if (skip(n))
1977
+ return "";
1978
+ if (n instanceof HTMLInputElement && (n.type === "submit" || n.type === "button"))
1979
+ return n.value;
1980
+ let full = "";
1981
+ for (let c = n.firstChild; c; c = c.nextSibling) {
1982
+ if (c.nodeType === Node.TEXT_NODE)
1983
+ full += c.nodeValue || "";
1984
+ else if (c.nodeType === Node.ELEMENT_NODE)
1985
+ full += read(c);
1986
+ }
1987
+ if (n.shadowRoot)
1988
+ full += read(n.shadowRoot);
1989
+ return full;
1990
+ };
1991
+ return read(root);
1992
+ }, void 0, { timeout: REF_RESOLVE_TIMEOUT_MS });
1993
+ return text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ");
1994
+ } catch (e) {
1995
+ traceDebug(`Live text read of ref ${ref} failed, using the snapshot: ${String(e?.message ?? e).split("\n")[0]}`);
1996
+ return void 0;
1997
+ }
1998
+ }
1999
+ static async _liveInputValue(page, ref) {
2000
+ try {
2001
+ return await page.locator(`aria-ref=${ref}`).inputValue({ timeout: REF_RESOLVE_TIMEOUT_MS });
2002
+ } catch (e) {
2003
+ traceDebug(`Live value read of ref ${ref} failed, using the snapshot: ${String(e?.message ?? e).split("\n")[0]}`);
2004
+ return void 0;
2005
+ }
2006
+ }
1938
2007
  /**
1939
- * Text value of an aria-snapshot line, in either form the renderer emits:
1940
- * `- generic "foo" [ref=e1]` (quoted accessible name) or
1941
- * `- generic [ref=e1]: foo` (colon-delimited text for nodes with no name).
1942
- * Returns null for a line that carries no text.
2008
+ * Make a locator pick out the ref's own element: a presence check's leaf made
2009
+ * exact (when asked), the SKYR-4263 test-id walk for an ambiguous text
2010
+ * locator, and — if the live page still finds more than one element — a pin
2011
+ * to the ref's element by position (`>> nth=K`, the form the exporter and
2012
+ * codegen already carry for clicks; SKYR-4505).
2013
+ *
2014
+ * `verdict`: `unique` (the live page found one match, the ref's own),
2015
+ * `pinned`, or `unverified` (no page, or the lookup could not place the ref;
2016
+ * `pinFailure` says why). `refusal` is set when what would be read or recorded
2017
+ * is not this element: the locator matches nothing or only other elements
2018
+ * (any kind), or it has a CSS link and could not be placed at all (CSS has no
2019
+ * snapshot count to fall back on — a `row >> td` chain included). A role or
2020
+ * text locator that could not be placed goes on unverified: a single-link
2021
+ * `visible` record is then counted against the snapshot; other kinds, chains
2022
+ * and the element visual snapshot path record it unpinned, as they did before
2023
+ * the pin existed. A ref inside an iframe (`f<N>e<M>`) gets its own refusal: the
2024
+ * lookup counts in the main frame, and the recorded selector has no frame.
1943
2025
  */
1944
- static _snapshotLineText(line) {
1945
- const quoted = line.match(/^\s*-\s*\w+\s+"([^"]*)"/);
1946
- if (quoted)
1947
- return quoted[1];
1948
- const colon = line.match(/\[ref=\w+\](?:\s*\[[^\]]*\])*\s*:\s*(.+?)\s*$/);
1949
- return colon?.[1]?.trim() || null;
2026
+ async _makeUnique(parsed, opts) {
2027
+ if (opts.exact)
2028
+ parsed = (0, import_snapshotLocators.exactLeaf)(parsed, opts.lineText);
2029
+ if (parsed.locator.kind === "text") {
2030
+ const improved = await this._improveTextSelector(opts.snapLines, opts.ref, parsed);
2031
+ if (improved) {
2032
+ traceDebug(`Improved ambiguous text selector to: ${improved.selector}`);
2033
+ parsed = improved;
2034
+ }
2035
+ }
2036
+ const page = this._browserBackend.context?.currentTab()?.page;
2037
+ if (!page)
2038
+ return { parsed, note: "", verdict: "unverified" };
2039
+ const pin = await TraceRecordingBackend._refIndexAmong(page, opts.ref, parsed.selector);
2040
+ if ("index" in pin) {
2041
+ if (pin.count === 1)
2042
+ return { parsed, note: "", verdict: "unique" };
2043
+ const unpinned = parsed.selector;
2044
+ parsed = (0, import_snapshotLocators.withNth)(parsed, pin.index);
2045
+ traceDebug(`${unpinned} matches ${pin.count} elements; pinned to the ref's own, nth=${pin.index}`);
2046
+ return { parsed, note: ` Recorded as ${parsed.selector}: ${unpinned} matches ${pin.count} elements, so the assertion is pinned to this one by position.`, verdict: "pinned" };
2047
+ }
2048
+ traceDebug(`Could not pin ref ${opts.ref} among ${parsed.selector}: ${pin.reason}${pin.detail ? ` (${pin.detail})` : ""}`);
2049
+ const pinFailure = pin.reason === "unresolved" ? `ref ${opts.ref} no longer resolves \u2014 take a new snapshot and use its ref` : pin.reason === "no-match" ? `it matches nothing on the live page` : pin.reason === "not-among-matches" ? `ref ${opts.ref} is not one of them \u2014 the hover locator points elsewhere; try the ref of the element that carries the property` : `placing ref ${opts.ref} among them failed (${pin.detail})`;
2050
+ const notThisElement = pin.reason === "no-match" || pin.reason === "not-among-matches";
2051
+ let hasCss = false;
2052
+ for (let link = parsed.locator; link; link = link.next)
2053
+ hasCss ||= link.kind === "default";
2054
+ if (!notThisElement && !hasCss)
2055
+ return { parsed, note: "", verdict: "unverified", pinFailure };
2056
+ const text = /^f\d+e\d+$/.test(opts.ref) ? `### Error
2057
+ "${opts.element}" (ref ${opts.ref}) is inside an iframe, and ${parsed.selector} addresses the main page only, so it cannot pick the element out at replay. Nothing was recorded. Assert it in the generated test through the frame (page.frameLocator(\u2026) or locator.contentFrame()).` : `### Error
2058
+ ${parsed.selector} does not pick out "${opts.element}" on the live page: ${pinFailure}. Nothing was recorded. Assert it through a test id or a role with its name, or on the ref of the element that carries it.`;
2059
+ return { parsed, note: "", verdict: "unverified", pinFailure, refusal: { content: [{ type: "text", text }], isError: true } };
2060
+ }
2061
+ /** How many elements `selector` matches on the live page and where the ref's
2062
+ * own element sits among them — one evaluateAll — or why it cannot tell: the
2063
+ * ref no longer resolves (stale snapshot), the selector matches nothing, the
2064
+ * ref is not one of the matches (a single match included), or the lookup
2065
+ * failed. An index is returned only when it is the ref's own element's. */
2066
+ static async _refIndexAmong(page, ref, selector) {
2067
+ let handle;
2068
+ try {
2069
+ handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: REF_RESOLVE_TIMEOUT_MS });
2070
+ } catch (e) {
2071
+ return { reason: "unresolved", detail: String(e?.message ?? e).split("\n")[0] };
2072
+ }
2073
+ if (!handle)
2074
+ return { reason: "unresolved" };
2075
+ try {
2076
+ const { count, index } = await page.locator(selector).evaluateAll((els, target) => ({ count: els.length, index: els.indexOf(target) }), handle);
2077
+ if (!count)
2078
+ return { reason: "no-match" };
2079
+ return index >= 0 ? { index, count } : { reason: "not-among-matches" };
2080
+ } catch (e) {
2081
+ return { reason: "error", detail: String(e?.message ?? e).split("\n")[0] };
2082
+ } finally {
2083
+ await handle.dispose().catch(() => {
2084
+ });
2085
+ }
2086
+ }
2087
+ /**
2088
+ * The locator to record for a ref: the hover's, unless it is missing or CSS
2089
+ * and the snapshot names the element better. A bare role (no name) is no
2090
+ * better than the hover's CSS unless that CSS is a framework-generated id,
2091
+ * and nothing in the snapshot beats a data-* test hook on the element itself:
2092
+ * a role name repeated across a list ("View Details" on every card) would
2093
+ * otherwise replace the one handle that picks this card out.
2094
+ * Shared by browser_assert and element visual snapshots so both paths agree.
2095
+ */
2096
+ _preferSnapshotLocator(parsed, refLine) {
2097
+ if (parsed && (parsed.locator.kind !== "default" || (0, import_snapshotLocators.isTestHookCss)(parsed.selector)))
2098
+ return parsed;
2099
+ const fromSnapshot = this._extractLocatorForRef(refLine);
2100
+ if (!fromSnapshot || !parsed)
2101
+ return fromSnapshot ?? parsed;
2102
+ const bareRole = fromSnapshot.locator.kind === "role" && typeof fromSnapshot.locator.options?.name !== "string";
2103
+ if (bareRole && !(0, import_snapshotLocators.isVolatileCss)(parsed.selector))
2104
+ return parsed;
2105
+ return fromSnapshot;
1950
2106
  }
1951
2107
  static {
1952
2108
  /** Extract selector and locator info from a snapshot line for assertion tracking. */
@@ -1999,42 +2155,27 @@ ${details}` }] };
1999
2155
  _extractLocatorForRef(refLine) {
2000
2156
  const testidMatch = refLine.match(/testid="([^"]+)"/);
2001
2157
  if (testidMatch) {
2002
- return {
2003
- selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
2004
- locator: { kind: "test-id", body: testidMatch[1], options: {} }
2005
- };
2158
+ const locator = { kind: "test-id", body: testidMatch[1], options: {} };
2159
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2006
2160
  }
2007
2161
  const roleMatch = refLine.match(/^\s*-\s*(\w+)\s+"([^"]*)"/);
2008
2162
  if (roleMatch && TraceRecordingBackend.ASSERTABLE_ROLES.has(roleMatch[1])) {
2009
- const role = roleMatch[1];
2010
- const name = (0, import_utils.stableTextLead)(roleMatch[2]);
2011
- return {
2012
- selector: `internal:role=${role}[name="${name}"i]`,
2013
- locator: { kind: "role", body: role, options: { attrs: [], exact: false, name } }
2014
- };
2163
+ const locator = { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, name: (0, import_utils.stableTextLead)(roleMatch[2]) } };
2164
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2015
2165
  }
2016
2166
  if (roleMatch && roleMatch[2]) {
2017
- const text = (0, import_utils.stableTextLead)(roleMatch[2]);
2018
- return {
2019
- selector: `internal:text="${text}"i`,
2020
- locator: { kind: "text", body: text, options: { exact: false } }
2021
- };
2167
+ const locator = { kind: "text", body: (0, import_utils.stableTextLead)(roleMatch[2]), options: { exact: false } };
2168
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2022
2169
  }
2023
2170
  const colonTextMatch = refLine.match(/\[ref=\w+\](?:\s*\[[^\]]*\])*\s*:\s*(.+?)\s*$/);
2024
- if (colonTextMatch && colonTextMatch[1].trim()) {
2025
- const text = (0, import_utils.stableTextLead)(colonTextMatch[1].trim());
2026
- return {
2027
- selector: `internal:text="${text}"i`,
2028
- locator: { kind: "text", body: text, options: { exact: false } }
2029
- };
2171
+ if (colonTextMatch && colonTextMatch[1].trim() && !(0, import_snapshotLocators.isInputLine)(refLine)) {
2172
+ const locator = { kind: "text", body: (0, import_utils.stableTextLead)((0, import_snapshotLocators.unquoteYaml)(colonTextMatch[1].trim())), options: { exact: false } };
2173
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2030
2174
  }
2031
2175
  const bareRoleMatch = refLine.match(/^\s*-\s*(\w+)(?:\s+\[[^\]]*\])*\s+\[ref=\w+\]/);
2032
2176
  if (bareRoleMatch && TraceRecordingBackend.ASSERTABLE_ROLES.has(bareRoleMatch[1])) {
2033
- const role = bareRoleMatch[1];
2034
- return {
2035
- selector: `internal:role=${role}`,
2036
- locator: { kind: "role", body: role, options: { attrs: [], exact: false } }
2037
- };
2177
+ const locator = { kind: "role", body: bareRoleMatch[1], options: { attrs: [], exact: false } };
2178
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2038
2179
  }
2039
2180
  return null;
2040
2181
  }
@@ -2440,7 +2581,7 @@ ${details}` }] };
2440
2581
  return null;
2441
2582
  let stripped = false;
2442
2583
  try {
2443
- const handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
2584
+ const handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: REF_RESOLVE_TIMEOUT_MS });
2444
2585
  if (!handle)
2445
2586
  return null;
2446
2587
  try {