@skyramp/mcp 0.4.3-rc.1 → 0.4.3

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 (37) hide show
  1. package/build/recommendation/answers.d.ts +5 -3
  2. package/build/recommendation/answers.js +5 -2
  3. package/build/recommendation/verifierContracts.d.ts +8 -0
  4. package/build/recommendation/verifierContracts.js +13 -0
  5. package/build/recommendation/verifiers/coverage.js +143 -4
  6. package/build/tools/code-refactor/removed-modules.d.ts +33 -0
  7. package/build/tools/code-refactor/removed-modules.js +61 -0
  8. package/build/tools/code-refactor/reuse-outcome.d.ts +27 -1
  9. package/build/tools/code-refactor/reuse-record-store.d.ts +5 -0
  10. package/build/tools/code-refactor/reuse-record-store.js +15 -1
  11. package/build/tools/code-refactor/reuse-state.d.ts +17 -4
  12. package/build/tools/code-refactor/reuse-state.js +67 -9
  13. package/build/tools/code-refactor/utils-verify-gates.js +39 -15
  14. package/build/tools/submitReportTool.js +47 -18
  15. package/build/tools/test-management/registerTestPlanTool.js +1 -1
  16. package/build/types/ReuseOutcome.d.ts +27 -0
  17. package/build/utils/utils-verify/allow.d.ts +1 -1
  18. package/build/utils/utils-verify/allow.js +15 -0
  19. package/build/utils/utils-verify/changed-lines.d.ts +48 -0
  20. package/build/utils/utils-verify/changed-lines.js +106 -0
  21. package/build/utils/utils-verify/head.d.ts +32 -7
  22. package/build/utils/utils-verify/head.js +55 -14
  23. package/build/utils/utils-verify/typecheck.d.ts +25 -4
  24. package/build/utils/utils-verify/typecheck.js +95 -13
  25. package/build/utils/utils-verify/verify.d.ts +24 -5
  26. package/build/utils/utils-verify/verify.js +156 -8
  27. package/build/utils/versions.d.ts +3 -3
  28. package/build/utils/versions.js +1 -1
  29. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +4 -2
  30. package/node_modules/playwright/lib/mcp/skyramp/snapshotLocators.js +246 -0
  31. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +221 -233
  32. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +21 -0
  33. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  34. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.ZYuEFHsi.js → index.uiPTFncY.js} +1 -1
  35. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  36. package/node_modules/playwright/package.json +1 -1
  37. package/package.json +3 -3
@@ -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,31 +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 === "default") {
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
- parsed = this._preferSnapshotLocator(parsed, refLine);
556
- }
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();
557
568
  }
558
- 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 };
559
600
  }
560
601
  /**
561
602
  * Load a prior Skyramp trace and replay it against the live browser, honoring
@@ -1320,46 +1361,23 @@ For "${params.type}" assertions, "property" (the ${params.type === "css" ? "CSS
1320
1361
  if ((params.type === "checked" || params.type === "disabled") && params.expected !== void 0 && params.expected !== "true" && params.expected !== "false")
1321
1362
  return { content: [{ type: "text", text: `### Error
1322
1363
  For "${params.type}" assertions, "expected" must be "true" or "false".` }], isError: true };
1323
- const hoverResult = await this._browserBackend.callTool("browser_hover", {
1324
- element: params.element,
1325
- ref: params.ref
1326
- });
1327
- if (hoverResult.isError) {
1328
- const errText = hoverResult.content?.[0]?.type === "text" ? hoverResult.content[0].text : "";
1329
- return { content: [{ type: "text", text: `### Assertion Failed
1330
- Could not resolve element ref=${params.ref}. ${errText}` }], isError: true };
1331
- }
1332
- const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
1333
- const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
1334
- if (!locatorMatch) {
1364
+ const resolved = await this._resolveRef(params.ref, params.element);
1365
+ if ("error" in resolved)
1335
1366
  return { content: [{ type: "text", text: `### Assertion Failed
1336
- Could not extract selector from hover result.` }], isError: true };
1337
- }
1338
- const locatorExpr = locatorMatch[1].trim();
1339
- let parsed = this._codeToLocator(locatorExpr);
1340
- const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
1341
- if (snapResult.isError) {
1342
- const errText = snapResult.content?.[0]?.type === "text" ? snapResult.content[0].text : "";
1343
- return { content: [{ type: "text", text: `### Assertion Failed
1344
- Could not capture page snapshot to verify the assertion. ${errText}` }], isError: true };
1345
- }
1346
- const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
1347
- const snapLines = snapText.split("\n");
1348
- const refLine = snapLines.find((l) => l.includes(`[ref=${params.ref}]`)) || "";
1349
- const resolved = this._preferSnapshotLocator(parsed, refLine);
1350
- if (resolved !== parsed)
1351
- traceDebug(`Resolved assert selector from snapshot accessible name (hover code was ${parsed ? "CSS" : "unparseable"}: ${locatorExpr})`);
1352
- parsed = resolved;
1353
- if (!parsed) {
1354
- return { content: [{ type: "text", text: `### Assertion Failed
1355
- Could not parse locator: ${locatorExpr}` }], isError: true };
1356
- }
1357
- const inputLine = TraceRecordingBackend._isInputLine(refLine);
1367
+ ${resolved.error}` }], isError: true };
1368
+ let parsed = resolved.parsed;
1369
+ const { snapLines, refIdx, refLine } = resolved;
1370
+ const inputLine = (0, import_snapshotLocators.isInputLine)(refLine);
1358
1371
  const quotedName = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/)?.[1] ?? "";
1359
- const lineText = TraceRecordingBackend._snapshotLineText(refLine) ?? "";
1360
- const elementText = inputLine ? quotedName : lineText;
1361
- const valueMatch = refLine.match(/\]:\s*(.+)$/);
1362
- const elementValue = valueMatch?.[1]?.trim() || "";
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) : "";
1363
1381
  let passed = false;
1364
1382
  let details = "";
1365
1383
  let countSelector = "";
@@ -1372,7 +1390,9 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
1372
1390
  const notReady = await this._requireLoadedPage(tab, "domcontentloaded");
1373
1391
  if (notReady)
1374
1392
  return notReady;
1375
- const unique = await this._makeUnique(parsed, { snapLines, ref: params.ref, lineText, exact: false });
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;
1376
1396
  parsed = unique.parsed;
1377
1397
  const live = tab.page.locator(parsed.selector);
1378
1398
  const kind = params.type;
@@ -1518,7 +1538,8 @@ Could not count "${refRole}" elements: ${e.message}` }], isError: true };
1518
1538
  }
1519
1539
  if (passed) {
1520
1540
  const recordedExpected = params.type === "text" && params.substring !== false ? (0, import_utils.stableTextLead)(params.expected) : params.expected;
1521
- const selectorText = parsed.locator.kind === "text" ? parsed.locator.body : parsed.locator.next?.kind === "text" ? parsed.locator.next.body : parsed.locator.options?.name ?? null;
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;
1522
1543
  const tautological = Boolean(params.type === "text" && selectorText && selectorText === recordedExpected);
1523
1544
  const recordType = tautological ? "visible" : params.type;
1524
1545
  if (recordType !== params.type) {
@@ -1526,11 +1547,13 @@ Could not count "${refRole}" elements: ${e.message}` }], isError: true };
1526
1547
  details += ` Recorded as a presence check (toBeVisible): the locator already selects by "${recordedExpected}".`;
1527
1548
  }
1528
1549
  if (params.type !== "count" && params.type !== "css" && params.type !== "class" && params.type !== "attribute") {
1529
- const unique = await this._makeUnique(parsed, { snapLines, ref: params.ref, lineText, exact: recordType === "visible" });
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;
1530
1553
  parsed = unique.parsed;
1531
1554
  details += unique.note;
1532
- if (recordType === "visible" && !unique.pinned && parsed.locator.kind !== "test-id") {
1533
- const matches = TraceRecordingBackend._snapshotMatches(snapLines, parsed.locator);
1555
+ if (recordType === "visible" && unique.verdict === "unverified") {
1556
+ const matches = (0, import_snapshotLocators.snapshotMatches)(snapLines, parsed.locator);
1534
1557
  if (matches !== void 0 && matches > 1) {
1535
1558
  return { content: [{ type: "text", text: `### Error
1536
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 };
@@ -1780,8 +1803,8 @@ ${text}` }], isError: true });
1780
1803
  let locator;
1781
1804
  if (params.testId?.trim()) {
1782
1805
  const testId = params.testId.trim();
1783
- selector = `internal:testid=[data-testid=${JSON.stringify(testId)}s]`;
1784
- 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);
1785
1808
  } else if (params.role?.trim()) {
1786
1809
  const role = params.role.trim();
1787
1810
  if (!TraceRecordingBackend.ASSERTABLE_ROLES.has(role))
@@ -1834,40 +1857,30 @@ ${text}` }], isError: true });
1834
1857
  ${details}` }] };
1835
1858
  }
1836
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
+ */
1837
1872
  _codeToLocator(expr) {
1838
- const testidMatch = expr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
1839
- if (testidMatch) {
1840
- const locator = { kind: "test-id", body: testidMatch[1], options: {} };
1841
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1842
- }
1843
- const roleMatch = expr.match(/getByRole\(\s*['"]([^'"]+)['"](?:\s*,\s*\{[^}]*name:\s*['"]([^'"]+)['"][^}]*\})?\s*\)/);
1844
- if (roleMatch) {
1845
- const locator = { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, ...roleMatch[2] ? { name: roleMatch[2] } : {} } };
1846
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1847
- }
1848
- const labelMatch = expr.match(/getByLabel\(\s*['"]([^'"]+)['"]\s*\)/);
1849
- if (labelMatch) {
1850
- const locator = { kind: "label", body: labelMatch[1], options: { exact: false } };
1851
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1852
- }
1853
- const textMatch = expr.match(/getByText\(\s*['"]([^'"]+)['"]\s*\)/);
1854
- if (textMatch) {
1855
- const locator = { kind: "text", body: textMatch[1], options: { exact: false } };
1856
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1857
- }
1858
- const ariaRefMatch = expr.match(/locator\(\s*['"]aria-ref=(\w+)['"]\s*\)/);
1859
- 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}`);
1860
1876
  return null;
1861
1877
  }
1862
- const cssMatch = expr.match(/locator\(\s*(?:'([^']+)'|"([^"]+)")\s*\)/);
1863
- const css = cssMatch?.[1] ?? cssMatch?.[2];
1864
- if (css && /^(?:[#.]|[a-z][\w-]*\[)/i.test(css)) {
1865
- return {
1866
- selector: css,
1867
- locator: { kind: "default", body: css, options: {} }
1868
- };
1869
- }
1870
- 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;
1871
1884
  }
1872
1885
  /**
1873
1886
  * When hover resolves to a text-based locator (e.g. getByText("$849.98")),
@@ -1877,7 +1890,7 @@ ${details}` }] };
1877
1890
  */
1878
1891
  async _improveTextSelector(snapLines, ref, original) {
1879
1892
  const textBody = original.locator.body;
1880
- const occurrences = TraceRecordingBackend._snapshotMatches(snapLines, original.locator) ?? 0;
1893
+ const occurrences = (0, import_snapshotLocators.snapshotMatches)(snapLines, original.locator) ?? 0;
1881
1894
  if (occurrences <= 1)
1882
1895
  return null;
1883
1896
  traceDebug(`Text "${textBody}" appears ${occurrences} times in snapshot \u2014 looking for parent test-id`);
@@ -1918,10 +1931,12 @@ ${details}` }] };
1918
1931
  const locator = {
1919
1932
  kind: "test-id",
1920
1933
  body: testidMatch[1],
1921
- options: {},
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 },
1922
1937
  next: { kind: "text", body: textBody, options: { exact: !!original.locator.options?.exact } }
1923
1938
  };
1924
- const chainedSelector = TraceRecordingBackend._renderSelector(locator);
1939
+ const chainedSelector = (0, import_snapshotLocators.renderSelector)(locator);
1925
1940
  traceDebug(`Improved to chained selector: ${chainedSelector}`);
1926
1941
  return { selector: chainedSelector, locator };
1927
1942
  }
@@ -1934,81 +1949,83 @@ ${details}` }] };
1934
1949
  }
1935
1950
  return null;
1936
1951
  }
1937
- static {
1938
- /** Roles whose colon text in the aria snapshot is the element's VALUE (what it
1939
- * holds), not text it renders: `- textbox [ref=e1]: Google Pixel 4`. */
1940
- this.INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton"]);
1941
- }
1942
- static _isInputLine(refLine) {
1943
- const role = refLine.match(/^\s*-\s*(\w+)/)?.[1];
1944
- return !!role && TraceRecordingBackend.INPUT_ROLES.has(role);
1945
- }
1946
- /** A CSS selector that is a framework-generated id (per the shared isDynamicId). */
1947
- static _isVolatileCss(selector) {
1948
- return selector.startsWith("#") && (0, import_dynamicId.isDynamicId)(selector.slice(1));
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);
1949
1963
  }
1950
- /**
1951
- * The one renderer from a locator object to its internal selector string.
1952
- * Values are JSON-quoted (as _handleAssertHidden does), so a name containing
1953
- * `"` still produces a valid selector; exactness is the `s` (exact) or `i`
1954
- * (substring, case-insensitive) suffix.
1955
- */
1956
- static _renderSelector(locator) {
1957
- const q = (v, exact) => `${JSON.stringify(v)}${exact ? "s" : "i"}`;
1958
- let head;
1959
- switch (locator.kind) {
1960
- case "test-id":
1961
- head = `internal:testid=[data-testid=${JSON.stringify(locator.body)}s]`;
1962
- break;
1963
- case "role":
1964
- head = `internal:role=${locator.body}${typeof locator.options?.name === "string" ? `[name=${q(locator.options.name, locator.options.exact)}]` : ""}`;
1965
- break;
1966
- case "text":
1967
- head = `internal:text=${q(locator.body, locator.options?.exact)}`;
1968
- break;
1969
- case "label":
1970
- head = `internal:label=${q(locator.body, locator.options?.exact)}`;
1971
- break;
1972
- case "nth":
1973
- head = `nth=${locator.body}`;
1974
- break;
1975
- default:
1976
- head = String(locator.body);
1977
- break;
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;
1978
1997
  }
1979
- return locator.next ? `${head} >> ${TraceRecordingBackend._renderSelector(locator.next)}` : head;
1980
1998
  }
1981
- /** The last link of a locator chain. */
1982
- static _leaf(locator) {
1983
- let tail = locator;
1984
- while (tail.next)
1985
- tail = tail.next;
1986
- return tail;
1987
- }
1988
- /** The chain with its leaf made exact, when the element's whole name/text is
1989
- * exactly the leaf's (so a sibling whose name merely contains it cannot match).
1990
- * Returns the locator unchanged otherwise, or for a leaf with no text. */
1991
- static _exactLeaf(parsed, fullText) {
1992
- const copy = (l) => ({ ...l, options: { ...l.options }, ...l.next ? { next: copy(l.next) } : {} });
1993
- const locator = copy(parsed.locator);
1994
- const leaf = TraceRecordingBackend._leaf(locator);
1995
- const leafText = leaf.kind === "text" ? leaf.body : leaf.kind === "role" ? leaf.options?.name : void 0;
1996
- if (typeof leafText !== "string" || leafText !== fullText)
1997
- return parsed;
1998
- leaf.options = { ...leaf.options, exact: true };
1999
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
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
+ }
2000
2006
  }
2001
2007
  /**
2002
2008
  * Make a locator pick out the ref's own element: a presence check's leaf made
2003
2009
  * exact (when asked), the SKYR-4263 test-id walk for an ambiguous text
2004
2010
  * locator, and — if the live page still finds more than one element — a pin
2005
2011
  * to the ref's element by position (`>> nth=K`, the form the exporter and
2006
- * codegen already carry for clicks; SKYR-4505). `note` describes a pin for the
2007
- * tool result; `pinFailure` says why an ambiguous locator could not be pinned.
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.
2008
2025
  */
2009
2026
  async _makeUnique(parsed, opts) {
2010
2027
  if (opts.exact)
2011
- parsed = TraceRecordingBackend._exactLeaf(parsed, opts.lineText);
2028
+ parsed = (0, import_snapshotLocators.exactLeaf)(parsed, opts.lineText);
2012
2029
  if (parsed.locator.kind === "text") {
2013
2030
  const improved = await this._improveTextSelector(opts.snapLines, opts.ref, parsed);
2014
2031
  if (improved) {
@@ -2018,28 +2035,38 @@ ${details}` }] };
2018
2035
  }
2019
2036
  const page = this._browserBackend.context?.currentTab()?.page;
2020
2037
  if (!page)
2021
- return { parsed, note: "", pinned: false };
2038
+ return { parsed, note: "", verdict: "unverified" };
2022
2039
  const pin = await TraceRecordingBackend._refIndexAmong(page, opts.ref, parsed.selector);
2023
2040
  if ("index" in pin) {
2024
- if (pin.count <= 1)
2025
- return { parsed, note: "", pinned: false };
2041
+ if (pin.count === 1)
2042
+ return { parsed, note: "", verdict: "unique" };
2026
2043
  const unpinned = parsed.selector;
2027
- parsed = TraceRecordingBackend._withNth(parsed, pin.index);
2044
+ parsed = (0, import_snapshotLocators.withNth)(parsed, pin.index);
2028
2045
  traceDebug(`${unpinned} matches ${pin.count} elements; pinned to the ref's own, nth=${pin.index}`);
2029
- return { parsed, note: ` Recorded as ${parsed.selector}: ${unpinned} matches ${pin.count} elements, so the assertion is pinned to this one by position.`, pinned: true };
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" };
2030
2047
  }
2031
2048
  traceDebug(`Could not pin ref ${opts.ref} among ${parsed.selector}: ${pin.reason}${pin.detail ? ` (${pin.detail})` : ""}`);
2032
- const pinFailure = pin.reason === "unresolved" ? `ref ${opts.ref} no longer resolves \u2014 take a new snapshot and use its ref` : 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})`;
2033
- return { parsed, note: "", pinned: false, pinFailure };
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 } };
2034
2060
  }
2035
2061
  /** How many elements `selector` matches on the live page and where the ref's
2036
2062
  * own element sits among them — one evaluateAll — or why it cannot tell: the
2037
- * ref no longer resolves (stale snapshot), it is not one of the matches, or
2038
- * the lookup failed. */
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. */
2039
2066
  static async _refIndexAmong(page, ref, selector) {
2040
2067
  let handle;
2041
2068
  try {
2042
- handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
2069
+ handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: REF_RESOLVE_TIMEOUT_MS });
2043
2070
  } catch (e) {
2044
2071
  return { reason: "unresolved", detail: String(e?.message ?? e).split("\n")[0] };
2045
2072
  }
@@ -2047,8 +2074,8 @@ ${details}` }] };
2047
2074
  return { reason: "unresolved" };
2048
2075
  try {
2049
2076
  const { count, index } = await page.locator(selector).evaluateAll((els, target) => ({ count: els.length, index: els.indexOf(target) }), handle);
2050
- if (count <= 1)
2051
- return { index: Math.max(index, 0), count };
2077
+ if (!count)
2078
+ return { reason: "no-match" };
2052
2079
  return index >= 0 ? { index, count } : { reason: "not-among-matches" };
2053
2080
  } catch (e) {
2054
2081
  return { reason: "error", detail: String(e?.message ?? e).split("\n")[0] };
@@ -2057,65 +2084,26 @@ ${details}` }] };
2057
2084
  });
2058
2085
  }
2059
2086
  }
2060
- /** The locator with a trailing `nth` link, re-rendered. */
2061
- static _withNth(parsed, index) {
2062
- const copy = (l) => ({ ...l, options: { ...l.options }, ...l.next ? { next: copy(l.next) } : {} });
2063
- const locator = copy(parsed.locator);
2064
- TraceRecordingBackend._leaf(locator).next = { kind: "nth", body: String(index), options: { attrs: [], exact: false } };
2065
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2066
- }
2067
- /**
2068
- * How many snapshot lines a single role or text locator matches, with the
2069
- * locator's own semantics: exact is equality, otherwise case-insensitive
2070
- * substring (what `"X"i` does at replay). Undefined for a shape it cannot
2071
- * count from the snapshot (a chain, CSS, a label).
2072
- */
2073
- static _snapshotMatches(snapLines, locator) {
2074
- if (locator.next)
2075
- return void 0;
2076
- const text = locator.kind === "text" ? locator.body : locator.kind === "role" ? locator.options?.name : void 0;
2077
- if (typeof text !== "string")
2078
- return void 0;
2079
- const exact = !!locator.options?.exact;
2080
- const same = (v) => v !== null && (exact ? v === text : v.toLowerCase().includes(text.toLowerCase()));
2081
- return snapLines.filter((l) => {
2082
- if (locator.kind === "role") {
2083
- const m = l.match(/^\s*-\s*(\w+)\s+"([^"]*)"/);
2084
- return !!m && m[1] === locator.body && same(m[2]);
2085
- }
2086
- return same(TraceRecordingBackend._snapshotLineText(l));
2087
- }).length;
2088
- }
2089
2087
  /**
2090
2088
  * The locator to record for a ref: the hover's, unless it is missing or CSS
2091
2089
  * and the snapshot names the element better. A bare role (no name) is no
2092
- * better than the hover's CSS unless that CSS is a framework-generated id.
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.
2093
2094
  * Shared by browser_assert and element visual snapshots so both paths agree.
2094
2095
  */
2095
2096
  _preferSnapshotLocator(parsed, refLine) {
2096
- if (parsed && parsed.locator.kind !== "default")
2097
+ if (parsed && (parsed.locator.kind !== "default" || (0, import_snapshotLocators.isTestHookCss)(parsed.selector)))
2097
2098
  return parsed;
2098
2099
  const fromSnapshot = this._extractLocatorForRef(refLine);
2099
2100
  if (!fromSnapshot || !parsed)
2100
2101
  return fromSnapshot ?? parsed;
2101
2102
  const bareRole = fromSnapshot.locator.kind === "role" && typeof fromSnapshot.locator.options?.name !== "string";
2102
- if (bareRole && !TraceRecordingBackend._isVolatileCss(parsed.selector))
2103
+ if (bareRole && !(0, import_snapshotLocators.isVolatileCss)(parsed.selector))
2103
2104
  return parsed;
2104
2105
  return fromSnapshot;
2105
2106
  }
2106
- /**
2107
- * Text value of an aria-snapshot line, in either form the renderer emits:
2108
- * `- generic "foo" [ref=e1]` (quoted accessible name) or
2109
- * `- generic [ref=e1]: foo` (colon-delimited text for nodes with no name).
2110
- * Returns null for a line that carries no text.
2111
- */
2112
- static _snapshotLineText(line) {
2113
- const quoted = line.match(/^\s*-\s*\w+\s+"([^"]*)"/);
2114
- if (quoted)
2115
- return quoted[1];
2116
- const colon = line.match(/\[ref=\w+\](?:\s*\[[^\]]*\])*\s*:\s*(.+?)\s*$/);
2117
- return colon?.[1]?.trim() || null;
2118
- }
2119
2107
  static {
2120
2108
  /** Extract selector and locator info from a snapshot line for assertion tracking. */
2121
2109
  // Roles that map to valid Playwright getByRole() selectors.
@@ -2168,26 +2156,26 @@ ${details}` }] };
2168
2156
  const testidMatch = refLine.match(/testid="([^"]+)"/);
2169
2157
  if (testidMatch) {
2170
2158
  const locator = { kind: "test-id", body: testidMatch[1], options: {} };
2171
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2159
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2172
2160
  }
2173
2161
  const roleMatch = refLine.match(/^\s*-\s*(\w+)\s+"([^"]*)"/);
2174
2162
  if (roleMatch && TraceRecordingBackend.ASSERTABLE_ROLES.has(roleMatch[1])) {
2175
2163
  const locator = { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, name: (0, import_utils.stableTextLead)(roleMatch[2]) } };
2176
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2164
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2177
2165
  }
2178
2166
  if (roleMatch && roleMatch[2]) {
2179
2167
  const locator = { kind: "text", body: (0, import_utils.stableTextLead)(roleMatch[2]), options: { exact: false } };
2180
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2168
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2181
2169
  }
2182
2170
  const colonTextMatch = refLine.match(/\[ref=\w+\](?:\s*\[[^\]]*\])*\s*:\s*(.+?)\s*$/);
2183
- if (colonTextMatch && colonTextMatch[1].trim() && !TraceRecordingBackend._isInputLine(refLine)) {
2184
- const locator = { kind: "text", body: (0, import_utils.stableTextLead)(colonTextMatch[1].trim()), options: { exact: false } };
2185
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
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 };
2186
2174
  }
2187
2175
  const bareRoleMatch = refLine.match(/^\s*-\s*(\w+)(?:\s+\[[^\]]*\])*\s+\[ref=\w+\]/);
2188
2176
  if (bareRoleMatch && TraceRecordingBackend.ASSERTABLE_ROLES.has(bareRoleMatch[1])) {
2189
2177
  const locator = { kind: "role", body: bareRoleMatch[1], options: { attrs: [], exact: false } };
2190
- return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2178
+ return { selector: (0, import_snapshotLocators.renderSelector)(locator), locator };
2191
2179
  }
2192
2180
  return null;
2193
2181
  }
@@ -2593,7 +2581,7 @@ ${details}` }] };
2593
2581
  return null;
2594
2582
  let stripped = false;
2595
2583
  try {
2596
- 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 });
2597
2585
  if (!handle)
2598
2586
  return null;
2599
2587
  try {