@skyramp/mcp 0.4.2 → 0.4.3-rc.1

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 (119) 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/fakeVerifyContext.d.ts +3 -0
  16. package/build/recommendation/fakeVerifyContext.js +16 -0
  17. package/build/recommendation/runVerifiers.js +2 -0
  18. package/build/recommendation/types.d.ts +12 -0
  19. package/build/recommendation/verifierContracts.d.ts +24 -2
  20. package/build/recommendation/verifierContracts.js +32 -4
  21. package/build/recommendation/verifiers/changedLinesClaimed.d.ts +17 -0
  22. package/build/recommendation/verifiers/changedLinesClaimed.js +88 -0
  23. package/build/recommendation/verifiers/expectedValueSourced.js +8 -0
  24. package/build/recommendation/verifiers/requirementSourced.js +141 -11
  25. package/build/services/TestGenerationService.js +4 -0
  26. package/build/tools/code-refactor/caller-gate.d.ts +105 -0
  27. package/build/tools/code-refactor/caller-gate.js +174 -0
  28. package/build/tools/code-refactor/changed-helper-report.d.ts +62 -0
  29. package/build/tools/code-refactor/changed-helper-report.js +114 -0
  30. package/build/tools/code-refactor/codeReuseTool.js +131 -62
  31. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -2
  32. package/build/tools/code-refactor/helper-callers.d.ts +65 -0
  33. package/build/tools/code-refactor/helper-callers.js +85 -0
  34. package/build/tools/code-refactor/modularizationTool.js +10 -0
  35. package/build/tools/code-refactor/pom-pass-state.d.ts +51 -0
  36. package/build/tools/code-refactor/pom-pass-state.js +115 -0
  37. package/build/tools/code-refactor/retrofit-state.d.ts +3 -2
  38. package/build/tools/code-refactor/retrofit-state.js +11 -12
  39. package/build/tools/code-refactor/reuse-outcome.d.ts +6 -0
  40. package/build/tools/code-refactor/reuse-record-store.d.ts +52 -0
  41. package/build/tools/code-refactor/reuse-record-store.js +126 -0
  42. package/build/tools/code-refactor/reuse-state.d.ts +18 -6
  43. package/build/tools/code-refactor/reuse-state.js +78 -131
  44. package/build/tools/code-refactor/utils-verify-gates.js +3 -3
  45. package/build/tools/code-refactor/verify-gates.d.ts +4 -0
  46. package/build/tools/code-refactor/verify-gates.js +8 -1
  47. package/build/tools/generate-tests/generateContractRestTool.js +7 -7
  48. package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -8
  49. package/build/tools/generate-tests/generateUIRestTool.js +3 -3
  50. package/build/tools/submitReportTool.js +91 -29
  51. package/build/tools/test-management/actionsTool.js +7 -0
  52. package/build/tools/test-management/registerTestPlanTool.d.ts +1 -0
  53. package/build/tools/test-management/registerTestPlanTool.js +70 -22
  54. package/build/types/RepositoryAnalysis.d.ts +20 -20
  55. package/build/types/ReuseOutcome.d.ts +96 -0
  56. package/build/types/TestExecution.d.ts +11 -0
  57. package/build/types/TestExecution.js +19 -0
  58. package/build/types/index.d.ts +1 -1
  59. package/build/utils/branchDiff.d.ts +1 -1
  60. package/build/utils/branchDiff.js +1 -1
  61. package/build/utils/changedRuns.d.ts +13 -0
  62. package/build/utils/changedRuns.js +56 -0
  63. package/build/utils/featureFlags.d.ts +8 -10
  64. package/build/utils/featureFlags.js +23 -21
  65. package/build/utils/normalizeSkyrampImports.d.ts +4 -1
  66. package/build/utils/normalizeSkyrampImports.js +24 -17
  67. package/build/utils/pom-scope/ownership.d.ts +19 -0
  68. package/build/utils/pom-scope/ownership.js +31 -0
  69. package/build/utils/pom-scope/pom-files.d.ts +8 -0
  70. package/build/utils/pom-scope/pom-files.js +8 -0
  71. package/build/utils/pom-scope/scoring.js +13 -1
  72. package/build/utils/pom-scope/strip.d.ts +10 -0
  73. package/build/utils/pom-scope/strip.js +9 -0
  74. package/build/utils/pom-verify/verify.js +13 -2
  75. package/build/utils/reuseRouting.d.ts +30 -2
  76. package/build/utils/reuseRouting.js +36 -15
  77. package/build/utils/testDependencyPolicy.js +4 -16
  78. package/build/utils/utils-verify/action-sites.d.ts +2 -2
  79. package/build/utils/utils-verify/body-reach.d.ts +2 -2
  80. package/build/utils/utils-verify/body-reach.js +4 -1
  81. package/build/utils/utils-verify/call-sites.d.ts +16 -6
  82. package/build/utils/utils-verify/call-sites.js +12 -6
  83. package/build/utils/utils-verify/changed-helpers.d.ts +38 -0
  84. package/build/utils/utils-verify/changed-helpers.js +91 -0
  85. package/build/utils/utils-verify/head.d.ts +48 -0
  86. package/build/utils/utils-verify/head.js +115 -0
  87. package/build/utils/utils-verify/importers.d.ts +59 -10
  88. package/build/utils/utils-verify/importers.js +121 -31
  89. package/build/utils/utils-verify/in-house.js +26 -31
  90. package/build/utils/utils-verify/index.d.ts +3 -0
  91. package/build/utils/utils-verify/index.js +3 -0
  92. package/build/utils/utils-verify/language-spec.js +10 -2
  93. package/build/utils/utils-verify/locate.d.ts +3 -0
  94. package/build/utils/utils-verify/locate.js +6 -6
  95. package/build/utils/utils-verify/parse.d.ts +13 -1
  96. package/build/utils/utils-verify/parse.js +122 -49
  97. package/build/utils/utils-verify/retrofit-equivalence.d.ts +0 -5
  98. package/build/utils/utils-verify/retrofit-equivalence.js +2 -34
  99. package/build/utils/utils-verify/stage.js +4 -16
  100. package/build/utils/utils-verify/verify.d.ts +9 -3
  101. package/build/utils/utils-verify/verify.js +26 -12
  102. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1 -0
  103. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +1 -0
  104. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +6 -0
  105. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +303 -150
  106. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  107. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-LNgEKtdV.js → codeMirrorModule-Bou8kKzE.js} +1 -1
  108. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-Bwr1eMKC.js → defaultSettingsView-DTp2-8Si.js} +3 -3
  109. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.BAkLd5DX.js → index.ZYuEFHsi.js} +1 -1
  110. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  111. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.BPopbasy.js → uiMode.GLqsx5cI.js} +1 -1
  112. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  113. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  114. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  115. package/node_modules/playwright/package.json +1 -1
  116. package/package.json +2 -2
  117. package/plugin/prompts/code-reuse/hand-off.md +13 -1
  118. package/plugin/prompts/plan-tests.md +2 -2
  119. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -0
@@ -547,14 +547,12 @@ Baseline "${input.filename}" (${params.snapshotType})${targetDesc} recorded; the
547
547
  return null;
548
548
  const locatorExpr = locatorMatch[1].trim();
549
549
  let parsed = this._codeToLocator(locatorExpr);
550
- if (!parsed || parsed.locator.kind === "css") {
550
+ if (!parsed || parsed.locator.kind === "default") {
551
551
  const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
552
552
  if (!snapResult.isError) {
553
553
  const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
554
554
  const refLine = snapText.split("\n").find((l) => l.includes(`[ref=${ref}]`)) || "";
555
- const fromSnapshot = this._extractLocatorForRef(refLine);
556
- if (fromSnapshot)
557
- parsed = fromSnapshot;
555
+ parsed = this._preferSnapshotLocator(parsed, refLine);
558
556
  }
559
557
  }
560
558
  return parsed;
@@ -1348,19 +1346,18 @@ Could not capture page snapshot to verify the assertion. ${errText}` }], isError
1348
1346
  const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
1349
1347
  const snapLines = snapText.split("\n");
1350
1348
  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
- }
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;
1358
1353
  if (!parsed) {
1359
1354
  return { content: [{ type: "text", text: `### Assertion Failed
1360
1355
  Could not parse locator: ${locatorExpr}` }], isError: true };
1361
1356
  }
1362
- const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
1363
- const elementText = textMatch?.[1] || "";
1357
+ const inputLine = TraceRecordingBackend._isInputLine(refLine);
1358
+ const quotedName = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/)?.[1] ?? "";
1359
+ const lineText = TraceRecordingBackend._snapshotLineText(refLine) ?? "";
1360
+ const elementText = inputLine ? quotedName : lineText;
1364
1361
  const valueMatch = refLine.match(/\]:\s*(.+)$/);
1365
1362
  const elementValue = valueMatch?.[1]?.trim() || "";
1366
1363
  let passed = false;
@@ -1375,13 +1372,8 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
1375
1372
  const notReady = await this._requireLoadedPage(tab, "domcontentloaded");
1376
1373
  if (notReady)
1377
1374
  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
- }
1375
+ const unique = await this._makeUnique(parsed, { snapLines, ref: params.ref, lineText, exact: false });
1376
+ parsed = unique.parsed;
1385
1377
  const live = tab.page.locator(parsed.selector);
1386
1378
  const kind = params.type;
1387
1379
  const name = kind === "class" ? "" : params.property.trim();
@@ -1440,6 +1432,18 @@ Could not read the un-hovered state of "${params.element}": the pointer could no
1440
1432
  const classes = (reading.value ?? "").split(" ").filter(Boolean);
1441
1433
  passed = classes.includes(className);
1442
1434
  details = passed ? `Class assertion passed: "${params.element}" carries class "${className}".` : `Class assertion FAILED: "${params.element}" has classes [${classes.join(" ")}], expected "${className}".`;
1435
+ if (!passed) {
1436
+ const depth = await live.evaluate((el, cls) => {
1437
+ let up = el.parentElement;
1438
+ for (let d = 1; up && d <= 3; d++, up = up.parentElement) {
1439
+ if (up.classList.contains(cls))
1440
+ return d;
1441
+ }
1442
+ return 0;
1443
+ }, className, { timeout: LIVE_READ_TIMEOUT_MS }).catch(() => 0);
1444
+ if (depth)
1445
+ 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.`;
1446
+ }
1443
1447
  } else {
1444
1448
  const actual = reading.value;
1445
1449
  passed = actual !== null && actual === params.expected;
@@ -1450,16 +1454,18 @@ Could not read the un-hovered state of "${params.element}": the pointer could no
1450
1454
  const msg = String(e?.message ?? e);
1451
1455
  if (/strict mode violation/i.test(msg))
1452
1456
  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 };
1457
+ 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
1458
  if (e?.name === "TimeoutError")
1455
1459
  return { content: [{ type: "text", text: `### Error
1456
1460
  "${params.element}" (${parsed.selector}) did not resolve within ${LIVE_READ_TIMEOUT_MS} ms. Re-snapshot and retry with a fresh ref.` }], isError: true };
1457
1461
  return { content: [{ type: "text", text: `### Error
1458
1462
  Could not read ${kind} of "${params.element}": ${msg}` }], isError: true };
1459
1463
  }
1464
+ if (passed)
1465
+ details += unique.note;
1460
1466
  } 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}".`;
1467
+ passed = elementText.includes(params.expected);
1468
+ 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
1469
  } else if (params.type === "value") {
1464
1470
  passed = elementValue === params.expected || elementValue.includes(params.expected);
1465
1471
  details = passed ? `Value assertion passed: "${params.element}" has value "${params.expected}".` : `Value assertion FAILED: "${params.element}" has value "${elementValue}", expected "${params.expected}".`;
@@ -1513,76 +1519,82 @@ Could not count "${refRole}" elements: ${e.message}` }], isError: true };
1513
1519
  if (passed) {
1514
1520
  const recordedExpected = params.type === "text" && params.substring !== false ? (0, import_utils.stableTextLead)(params.expected) : params.expected;
1515
1521
  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;
1522
+ const tautological = Boolean(params.type === "text" && selectorText && selectorText === recordedExpected);
1523
+ const recordType = tautological ? "visible" : params.type;
1524
+ if (recordType !== params.type) {
1525
+ traceDebug(`Tautological text assertion recorded as presence: selector already matches "${recordedExpected}"`);
1526
+ details += ` Recorded as a presence check (toBeVisible): the locator already selects by "${recordedExpected}".`;
1527
+ }
1528
+ 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" });
1530
+ parsed = unique.parsed;
1531
+ details += unique.note;
1532
+ if (recordType === "visible" && !unique.pinned && parsed.locator.kind !== "test-id") {
1533
+ const matches = TraceRecordingBackend._snapshotMatches(snapLines, parsed.locator);
1534
+ if (matches !== void 0 && matches > 1) {
1535
+ return { content: [{ type: "text", text: `### Error
1536
+ "${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
1537
  }
1526
1538
  }
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;
1539
+ }
1540
+ let assertName;
1541
+ let args;
1542
+ let code;
1543
+ switch (recordType) {
1544
+ case "checked": {
1545
+ const expectChecked = params.expected !== "false";
1546
+ assertName = "assertChecked";
1547
+ args = { type: "checked", ref: params.ref, selector: parsed.selector, locator: parsed.locator, checked: expectChecked };
1548
+ code = `assertChecked:${parsed.selector}:${expectChecked}`;
1549
+ break;
1550
+ }
1551
+ case "disabled": {
1552
+ const expectDisabled = params.expected !== "false";
1553
+ assertName = "assertDisabled";
1554
+ args = { type: "disabled", ref: params.ref, selector: parsed.selector, locator: parsed.locator, disabled: expectDisabled };
1555
+ code = `assertDisabled:${parsed.selector}:${expectDisabled}`;
1556
+ break;
1580
1557
  }
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}`);
1558
+ case "visible":
1559
+ assertName = "assertVisible";
1560
+ args = { type: "visible", ref: params.ref, selector: parsed.selector, locator: parsed.locator };
1561
+ code = `assertVisible:${parsed.selector}`;
1562
+ break;
1563
+ case "count":
1564
+ assertName = "assertCount";
1565
+ 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) };
1566
+ code = `assertCount:${countSelector}:${params.expected}`;
1567
+ break;
1568
+ case "value":
1569
+ assertName = "assertValue";
1570
+ args = { type: "value", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: recordedExpected };
1571
+ code = `assertValue:${parsed.selector}:${recordedExpected}`;
1572
+ break;
1573
+ case "css":
1574
+ assertName = "assertCSS";
1575
+ args = { type: "css", ref: params.ref, selector: parsed.selector, locator: parsed.locator, property: params.property.trim(), expected: liveValue ?? params.expected };
1576
+ code = `assertCSS:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1577
+ break;
1578
+ case "class":
1579
+ assertName = "assertClass";
1580
+ args = { type: "class", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: params.expected.trim() };
1581
+ code = `assertClass:${parsed.selector}:${params.expected.trim()}`;
1582
+ break;
1583
+ case "attribute":
1584
+ assertName = "assertAttribute";
1585
+ args = { type: "attribute", ref: params.ref, selector: parsed.selector, locator: parsed.locator, property: params.property.trim(), expected: liveValue ?? params.expected };
1586
+ code = `assertAttribute:${parsed.selector}:${params.property.trim()}:${liveValue ?? params.expected}`;
1587
+ break;
1588
+ default:
1589
+ assertName = "assertText";
1590
+ args = { type: "text", ref: params.ref, selector: parsed.selector, locator: parsed.locator, expected: recordedExpected, substring: params.substring };
1591
+ code = `assertText:${parsed.selector}:${recordedExpected}:${params.substring}`;
1592
+ break;
1585
1593
  }
1594
+ if (unhoverMove)
1595
+ this._trackedActions.push({ toolName: "browser_mouse_action", args: { name: "mouse.move", position: unhoverMove }, code: "", timestamp: timestamp - 1, pageAlias: this._currentPageAlias });
1596
+ this._trackedActions.push({ toolName: "browser_assert", args, code, timestamp, pageAlias: this._currentPageAlias });
1597
+ traceDebug(`Assert: ${assertName} with selector ${args.selector}`);
1586
1598
  }
1587
1599
  return {
1588
1600
  content: [{ type: "text", text: `### ${passed ? "Assertion Passed" : "Assertion Failed"}
@@ -1825,41 +1837,34 @@ ${details}` }] };
1825
1837
  _codeToLocator(expr) {
1826
1838
  const testidMatch = expr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
1827
1839
  if (testidMatch) {
1828
- return {
1829
- selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
1830
- locator: { kind: "test-id", body: testidMatch[1], options: {} }
1831
- };
1840
+ const locator = { kind: "test-id", body: testidMatch[1], options: {} };
1841
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1832
1842
  }
1833
1843
  const roleMatch = expr.match(/getByRole\(\s*['"]([^'"]+)['"](?:\s*,\s*\{[^}]*name:\s*['"]([^'"]+)['"][^}]*\})?\s*\)/);
1834
1844
  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
- };
1845
+ const locator = { kind: "role", body: roleMatch[1], options: { attrs: [], exact: false, ...roleMatch[2] ? { name: roleMatch[2] } : {} } };
1846
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1839
1847
  }
1840
1848
  const labelMatch = expr.match(/getByLabel\(\s*['"]([^'"]+)['"]\s*\)/);
1841
1849
  if (labelMatch) {
1842
- return {
1843
- selector: `internal:label="${labelMatch[1]}"i`,
1844
- locator: { kind: "label", body: labelMatch[1], options: { exact: false } }
1845
- };
1850
+ const locator = { kind: "label", body: labelMatch[1], options: { exact: false } };
1851
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1846
1852
  }
1847
1853
  const textMatch = expr.match(/getByText\(\s*['"]([^'"]+)['"]\s*\)/);
1848
1854
  if (textMatch) {
1849
- return {
1850
- selector: `internal:text="${textMatch[1]}"i`,
1851
- locator: { kind: "text", body: textMatch[1], options: { exact: false } }
1852
- };
1855
+ const locator = { kind: "text", body: textMatch[1], options: { exact: false } };
1856
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
1853
1857
  }
1854
1858
  const ariaRefMatch = expr.match(/locator\(\s*['"]aria-ref=(\w+)['"]\s*\)/);
1855
1859
  if (ariaRefMatch) {
1856
1860
  return null;
1857
1861
  }
1858
- const cssMatch = expr.match(/locator\(\s*['"]([#.][^'"]+)['"]\s*\)/);
1859
- if (cssMatch) {
1862
+ const cssMatch = expr.match(/locator\(\s*(?:'([^']+)'|"([^"]+)")\s*\)/);
1863
+ const css = cssMatch?.[1] ?? cssMatch?.[2];
1864
+ if (css && /^(?:[#.]|[a-z][\w-]*\[)/i.test(css)) {
1860
1865
  return {
1861
- selector: cssMatch[1],
1862
- locator: { kind: "css", body: cssMatch[1], options: {} }
1866
+ selector: css,
1867
+ locator: { kind: "default", body: css, options: {} }
1863
1868
  };
1864
1869
  }
1865
1870
  return null;
@@ -1872,10 +1877,7 @@ ${details}` }] };
1872
1877
  */
1873
1878
  async _improveTextSelector(snapLines, ref, original) {
1874
1879
  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;
1880
+ const occurrences = TraceRecordingBackend._snapshotMatches(snapLines, original.locator) ?? 0;
1879
1881
  if (occurrences <= 1)
1880
1882
  return null;
1881
1883
  traceDebug(`Text "${textBody}" appears ${occurrences} times in snapshot \u2014 looking for parent test-id`);
@@ -1913,18 +1915,15 @@ ${details}` }] };
1913
1915
  const ancestorExpr = ancestorLocatorMatch[1].trim();
1914
1916
  const testidMatch = ancestorExpr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
1915
1917
  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
- }
1918
+ const locator = {
1919
+ kind: "test-id",
1920
+ body: testidMatch[1],
1921
+ options: {},
1922
+ next: { kind: "text", body: textBody, options: { exact: !!original.locator.options?.exact } }
1927
1923
  };
1924
+ const chainedSelector = TraceRecordingBackend._renderSelector(locator);
1925
+ traceDebug(`Improved to chained selector: ${chainedSelector}`);
1926
+ return { selector: chainedSelector, locator };
1928
1927
  }
1929
1928
  }
1930
1929
  }
@@ -1935,6 +1934,175 @@ ${details}` }] };
1935
1934
  }
1936
1935
  return null;
1937
1936
  }
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));
1949
+ }
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;
1978
+ }
1979
+ return locator.next ? `${head} >> ${TraceRecordingBackend._renderSelector(locator.next)}` : head;
1980
+ }
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 };
2000
+ }
2001
+ /**
2002
+ * Make a locator pick out the ref's own element: a presence check's leaf made
2003
+ * exact (when asked), the SKYR-4263 test-id walk for an ambiguous text
2004
+ * locator, and — if the live page still finds more than one element — a pin
2005
+ * 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.
2008
+ */
2009
+ async _makeUnique(parsed, opts) {
2010
+ if (opts.exact)
2011
+ parsed = TraceRecordingBackend._exactLeaf(parsed, opts.lineText);
2012
+ if (parsed.locator.kind === "text") {
2013
+ const improved = await this._improveTextSelector(opts.snapLines, opts.ref, parsed);
2014
+ if (improved) {
2015
+ traceDebug(`Improved ambiguous text selector to: ${improved.selector}`);
2016
+ parsed = improved;
2017
+ }
2018
+ }
2019
+ const page = this._browserBackend.context?.currentTab()?.page;
2020
+ if (!page)
2021
+ return { parsed, note: "", pinned: false };
2022
+ const pin = await TraceRecordingBackend._refIndexAmong(page, opts.ref, parsed.selector);
2023
+ if ("index" in pin) {
2024
+ if (pin.count <= 1)
2025
+ return { parsed, note: "", pinned: false };
2026
+ const unpinned = parsed.selector;
2027
+ parsed = TraceRecordingBackend._withNth(parsed, pin.index);
2028
+ 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 };
2030
+ }
2031
+ 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 };
2034
+ }
2035
+ /** How many elements `selector` matches on the live page and where the ref's
2036
+ * 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. */
2039
+ static async _refIndexAmong(page, ref, selector) {
2040
+ let handle;
2041
+ try {
2042
+ handle = await page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 1e3 });
2043
+ } catch (e) {
2044
+ return { reason: "unresolved", detail: String(e?.message ?? e).split("\n")[0] };
2045
+ }
2046
+ if (!handle)
2047
+ return { reason: "unresolved" };
2048
+ try {
2049
+ 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 };
2052
+ return index >= 0 ? { index, count } : { reason: "not-among-matches" };
2053
+ } catch (e) {
2054
+ return { reason: "error", detail: String(e?.message ?? e).split("\n")[0] };
2055
+ } finally {
2056
+ await handle.dispose().catch(() => {
2057
+ });
2058
+ }
2059
+ }
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
+ /**
2090
+ * The locator to record for a ref: the hover's, unless it is missing or CSS
2091
+ * 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.
2093
+ * Shared by browser_assert and element visual snapshots so both paths agree.
2094
+ */
2095
+ _preferSnapshotLocator(parsed, refLine) {
2096
+ if (parsed && parsed.locator.kind !== "default")
2097
+ return parsed;
2098
+ const fromSnapshot = this._extractLocatorForRef(refLine);
2099
+ if (!fromSnapshot || !parsed)
2100
+ return fromSnapshot ?? parsed;
2101
+ const bareRole = fromSnapshot.locator.kind === "role" && typeof fromSnapshot.locator.options?.name !== "string";
2102
+ if (bareRole && !TraceRecordingBackend._isVolatileCss(parsed.selector))
2103
+ return parsed;
2104
+ return fromSnapshot;
2105
+ }
1938
2106
  /**
1939
2107
  * Text value of an aria-snapshot line, in either form the renderer emits:
1940
2108
  * `- generic "foo" [ref=e1]` (quoted accessible name) or
@@ -1999,42 +2167,27 @@ ${details}` }] };
1999
2167
  _extractLocatorForRef(refLine) {
2000
2168
  const testidMatch = refLine.match(/testid="([^"]+)"/);
2001
2169
  if (testidMatch) {
2002
- return {
2003
- selector: `internal:testid=[data-testid="${testidMatch[1]}"s]`,
2004
- locator: { kind: "test-id", body: testidMatch[1], options: {} }
2005
- };
2170
+ const locator = { kind: "test-id", body: testidMatch[1], options: {} };
2171
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2006
2172
  }
2007
2173
  const roleMatch = refLine.match(/^\s*-\s*(\w+)\s+"([^"]*)"/);
2008
2174
  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
- };
2175
+ 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 };
2015
2177
  }
2016
2178
  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
- };
2179
+ const locator = { kind: "text", body: (0, import_utils.stableTextLead)(roleMatch[2]), options: { exact: false } };
2180
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2022
2181
  }
2023
2182
  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
- };
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 };
2030
2186
  }
2031
2187
  const bareRoleMatch = refLine.match(/^\s*-\s*(\w+)(?:\s+\[[^\]]*\])*\s+\[ref=\w+\]/);
2032
2188
  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
- };
2189
+ const locator = { kind: "role", body: bareRoleMatch[1], options: { attrs: [], exact: false } };
2190
+ return { selector: TraceRecordingBackend._renderSelector(locator), locator };
2038
2191
  }
2039
2192
  return null;
2040
2193
  }