@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.
- package/build/execution/wrapperConfig.d.ts +4 -0
- package/build/execution/wrapperConfig.js +14 -2
- package/build/prompts/code-reuse.d.ts +5 -1
- package/build/prompts/code-reuse.js +16 -10
- package/build/prompts/modularization/ui-test-modularization.js +9 -2
- package/build/prompts/pom-aware-code-reuse.d.ts +4 -1
- package/build/prompts/pom-aware-code-reuse.js +38 -6
- package/build/prompts/reuse-hand-off.d.ts +4 -0
- package/build/prompts/reuse-hand-off.js +10 -5
- package/build/prompts/shared-helper-policy.d.ts +5 -0
- package/build/prompts/shared-helper-policy.js +5 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +2 -1
- package/build/prompts/testbot/testbot-prompts.js +33 -16
- package/build/recommendation/answers.d.ts +5 -3
- package/build/recommendation/answers.js +5 -2
- package/build/recommendation/fakeVerifyContext.d.ts +3 -0
- package/build/recommendation/fakeVerifyContext.js +16 -0
- package/build/recommendation/runVerifiers.js +2 -0
- package/build/recommendation/types.d.ts +12 -0
- package/build/recommendation/verifierContracts.d.ts +32 -2
- package/build/recommendation/verifierContracts.js +45 -4
- package/build/recommendation/verifiers/changedLinesClaimed.d.ts +17 -0
- package/build/recommendation/verifiers/changedLinesClaimed.js +88 -0
- package/build/recommendation/verifiers/coverage.js +143 -4
- package/build/recommendation/verifiers/expectedValueSourced.js +8 -0
- package/build/recommendation/verifiers/requirementSourced.js +141 -11
- package/build/services/TestGenerationService.js +4 -0
- package/build/tools/code-refactor/caller-gate.d.ts +105 -0
- package/build/tools/code-refactor/caller-gate.js +174 -0
- package/build/tools/code-refactor/changed-helper-report.d.ts +62 -0
- package/build/tools/code-refactor/changed-helper-report.js +114 -0
- package/build/tools/code-refactor/codeReuseTool.js +131 -62
- package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -2
- package/build/tools/code-refactor/helper-callers.d.ts +65 -0
- package/build/tools/code-refactor/helper-callers.js +85 -0
- package/build/tools/code-refactor/modularizationTool.js +10 -0
- package/build/tools/code-refactor/pom-pass-state.d.ts +51 -0
- package/build/tools/code-refactor/pom-pass-state.js +115 -0
- package/build/tools/code-refactor/removed-modules.d.ts +33 -0
- package/build/tools/code-refactor/removed-modules.js +61 -0
- package/build/tools/code-refactor/retrofit-state.d.ts +3 -2
- package/build/tools/code-refactor/retrofit-state.js +11 -12
- package/build/tools/code-refactor/reuse-outcome.d.ts +33 -1
- package/build/tools/code-refactor/reuse-record-store.d.ts +57 -0
- package/build/tools/code-refactor/reuse-record-store.js +140 -0
- package/build/tools/code-refactor/reuse-state.d.ts +34 -9
- package/build/tools/code-refactor/reuse-state.js +140 -135
- package/build/tools/code-refactor/utils-verify-gates.js +42 -18
- package/build/tools/code-refactor/verify-gates.d.ts +4 -0
- package/build/tools/code-refactor/verify-gates.js +8 -1
- package/build/tools/generate-tests/generateContractRestTool.js +7 -7
- package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -8
- package/build/tools/generate-tests/generateUIRestTool.js +3 -3
- package/build/tools/submitReportTool.js +121 -30
- package/build/tools/test-management/actionsTool.js +7 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +1 -0
- package/build/tools/test-management/registerTestPlanTool.js +71 -23
- package/build/types/RepositoryAnalysis.d.ts +20 -20
- package/build/types/ReuseOutcome.d.ts +123 -0
- package/build/types/TestExecution.d.ts +11 -0
- package/build/types/TestExecution.js +19 -0
- package/build/types/index.d.ts +1 -1
- package/build/utils/branchDiff.d.ts +1 -1
- package/build/utils/branchDiff.js +1 -1
- package/build/utils/changedRuns.d.ts +13 -0
- package/build/utils/changedRuns.js +56 -0
- package/build/utils/featureFlags.d.ts +8 -10
- package/build/utils/featureFlags.js +23 -21
- package/build/utils/normalizeSkyrampImports.d.ts +4 -1
- package/build/utils/normalizeSkyrampImports.js +24 -17
- package/build/utils/pom-scope/ownership.d.ts +19 -0
- package/build/utils/pom-scope/ownership.js +31 -0
- package/build/utils/pom-scope/pom-files.d.ts +8 -0
- package/build/utils/pom-scope/pom-files.js +8 -0
- package/build/utils/pom-scope/scoring.js +13 -1
- package/build/utils/pom-scope/strip.d.ts +10 -0
- package/build/utils/pom-scope/strip.js +9 -0
- package/build/utils/pom-verify/verify.js +13 -2
- package/build/utils/reuseRouting.d.ts +30 -2
- package/build/utils/reuseRouting.js +36 -15
- package/build/utils/testDependencyPolicy.js +4 -16
- package/build/utils/utils-verify/action-sites.d.ts +2 -2
- package/build/utils/utils-verify/allow.d.ts +1 -1
- package/build/utils/utils-verify/allow.js +15 -0
- package/build/utils/utils-verify/body-reach.d.ts +2 -2
- package/build/utils/utils-verify/body-reach.js +4 -1
- package/build/utils/utils-verify/call-sites.d.ts +16 -6
- package/build/utils/utils-verify/call-sites.js +12 -6
- package/build/utils/utils-verify/changed-helpers.d.ts +38 -0
- package/build/utils/utils-verify/changed-helpers.js +91 -0
- package/build/utils/utils-verify/changed-lines.d.ts +48 -0
- package/build/utils/utils-verify/changed-lines.js +106 -0
- package/build/utils/utils-verify/head.d.ts +73 -0
- package/build/utils/utils-verify/head.js +156 -0
- package/build/utils/utils-verify/importers.d.ts +59 -10
- package/build/utils/utils-verify/importers.js +121 -31
- package/build/utils/utils-verify/in-house.js +26 -31
- package/build/utils/utils-verify/index.d.ts +3 -0
- package/build/utils/utils-verify/index.js +3 -0
- package/build/utils/utils-verify/language-spec.js +10 -2
- package/build/utils/utils-verify/locate.d.ts +3 -0
- package/build/utils/utils-verify/locate.js +6 -6
- package/build/utils/utils-verify/parse.d.ts +13 -1
- package/build/utils/utils-verify/parse.js +122 -49
- package/build/utils/utils-verify/retrofit-equivalence.d.ts +0 -5
- package/build/utils/utils-verify/retrofit-equivalence.js +2 -34
- package/build/utils/utils-verify/stage.js +4 -16
- package/build/utils/utils-verify/typecheck.d.ts +25 -4
- package/build/utils/utils-verify/typecheck.js +95 -13
- package/build/utils/utils-verify/verify.d.ts +33 -8
- package/build/utils/utils-verify/verify.js +180 -18
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +1 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +4 -2
- package/node_modules/playwright/lib/mcp/skyramp/snapshotLocators.js +246 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +378 -237
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +21 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-LNgEKtdV.js → codeMirrorModule-Bou8kKzE.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-Bwr1eMKC.js → defaultSettingsView-DTp2-8Si.js} +3 -3
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.BAkLd5DX.js → index.uiPTFncY.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.BPopbasy.js → uiMode.GLqsx5cI.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
- package/plugin/prompts/code-reuse/hand-off.md +13 -1
- package/plugin/prompts/plan-tests.md +2 -2
- 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 (
|
|
494
|
-
return
|
|
495
|
-
|
|
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
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
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
|
|
546
|
+
async _resolveRef(ref, element) {
|
|
541
547
|
const hoverResult = await this._browserBackend.callTool("browser_hover", { element, ref });
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (!
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
|
1326
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
const
|
|
1363
|
-
const
|
|
1364
|
-
const
|
|
1365
|
-
const
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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)
|
|
1462
|
-
details = passed ? `Text assertion passed: "${params.element}" contains "${params.expected}".` : `Text assertion FAILED: "${params.element}" has text "${elementText}"${elementValue ? `
|
|
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
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
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
|
|
1827
|
-
if (
|
|
1828
|
-
|
|
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
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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.
|
|
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
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
selector
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
*
|
|
1940
|
-
*
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
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
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
-
|
|
2003
|
-
|
|
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
|
|
2010
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 {
|