diff --git a/src/app/services/record-service.test.ts b/src/app/services/record-service.test.ts index 8941ef2..1a60cc4 100644 --- a/src/app/services/record-service.test.ts +++ b/src/app/services/record-service.test.ts @@ -136,6 +136,7 @@ describe("runRecord auto-improve", () => { applySelectors: true, applyAssertions: true, assertions: "candidates", + assertionPolicy: "reliable", }); expect(ui.info).toHaveBeenCalledWith("Auto-improve: no changes needed"); }); diff --git a/src/app/services/record-service.ts b/src/app/services/record-service.ts index e3cd2f4..9b5877a 100644 --- a/src/app/services/record-service.ts +++ b/src/app/services/record-service.ts @@ -138,6 +138,7 @@ export async function runRecord(opts: RecordCliOptions): Promise { applySelectors: true, applyAssertions: true, assertions: "candidates", + assertionPolicy: "reliable", }); const summary = improveResult.report.summary; diff --git a/src/core/improve/assertion-candidates-snapshot.test.ts b/src/core/improve/assertion-candidates-snapshot.test.ts index b0ce7d0..c7f5897 100644 --- a/src/core/improve/assertion-candidates-snapshot.test.ts +++ b/src/core/improve/assertion-candidates-snapshot.test.ts @@ -117,6 +117,78 @@ describe("snapshot assertion candidates", () => { expect(out).toHaveLength(0); }); + it("suppresses snapshot text assertions for navigation-like dynamic clicks", () => { + const dynamicLinkText = + "Live update 12:30: Market story shifts quickly after morning session"; + const preUrl = "https://example.test/home"; + const postUrl = "https://example.test/category"; + const out = buildSnapshotAssertionCandidates([ + { + index: 1, + step: { + action: "click", + target: { + value: `getByRole('link', { name: '${dynamicLinkText}', exact: true })`, + kind: "locatorExpression", + source: "manual", + }, + }, + preSnapshot: `- generic [ref=e1]:\n - link "Top story" [ref=e2]\n`, + postSnapshot: `- generic [ref=e1]:\n - heading "Category page" [level=1] [ref=e3]\n`, + preUrl, + postUrl, + }, + ], "snapshot_native"); + + expect(out.some((candidate) => candidate.candidate.action === "assertText")).toBe(false); + expect(out.some((candidate) => candidate.candidate.action === "assertVisible")).toBe(false); + expect(out.some((candidate) => candidate.candidate.action === "assertEnabled")).toBe(false); + expect(out.some((candidate) => candidate.candidate.action === "assertUrl")).toBe(true); + expect(out).toHaveLength(1); + }); + + it("keeps snapshot content assertions for stable exact link clicks", () => { + const out = buildSnapshotAssertionCandidates([ + { + index: 0, + step: { + action: "click", + target: { + value: "getByRole('link', { name: 'Settings', exact: true })", + kind: "locatorExpression", + source: "manual", + }, + }, + preSnapshot: "- generic [ref=e1]:\n", + postSnapshot: + "- generic [ref=e1]:\n - heading \"Account settings\" [level=1] [ref=e2]\n", + }, + ], "snapshot_native"); + + expect(out.some((candidate) => candidate.candidate.action === "assertText")).toBe(true); + }); + + it("keeps snapshot content assertions for stable selectors with story/article ids", () => { + const out = buildSnapshotAssertionCandidates([ + { + index: 0, + step: { + action: "click", + target: { + value: "locator('#user-story-tab')", + kind: "locatorExpression", + source: "manual", + }, + }, + preSnapshot: "- generic [ref=e1]:\n", + postSnapshot: + "- generic [ref=e1]:\n - heading \"Profile details\" [level=1] [ref=e2]\n", + }, + ], "snapshot_native"); + + expect(out.some((candidate) => candidate.candidate.action === "assertText")).toBe(true); + }); + it("generates multiple candidates from a rich delta", () => { const out = buildSnapshotAssertionCandidates([richDeltaStepSnapshot], "snapshot_native"); diff --git a/src/core/improve/assertion-candidates-snapshot.ts b/src/core/improve/assertion-candidates-snapshot.ts index 9274677..c081d61 100644 --- a/src/core/improve/assertion-candidates-snapshot.ts +++ b/src/core/improve/assertion-candidates-snapshot.ts @@ -23,6 +23,7 @@ import { normalizeForCompare, } from "./assertion-candidates-snapshot-shared.js"; import { parseSnapshotNodes } from "./assertion-candidates-snapshot-parser.js"; +import { classifyNavigationLikeInteraction } from "./navigation-like-interaction.js"; export interface StepSnapshot { index: number; @@ -47,14 +48,19 @@ export function buildSnapshotAssertionCandidates( const delta = buildDeltaNodes(preNodes, postNodes); const actedTargetHint = extractActedTargetHint(snapshot.step); - const framePath = + const stepTarget = snapshot.step.action !== "navigate" && snapshot.step.action !== "assertUrl" && snapshot.step.action !== "assertTitle" && "target" in snapshot.step && snapshot.step.target - ? snapshot.step.target.framePath + ? snapshot.step.target : undefined; + const suppressContentCandidates = stepTarget + ? Boolean(classifyNavigationLikeInteraction(snapshot.step, stepTarget)) + : false; + const framePath = + stepTarget ? stepTarget.framePath : undefined; if (snapshot.step.action === "click") { const stableNodes = buildStableNodes(preNodes, postNodes); @@ -89,42 +95,48 @@ export function buildSnapshotAssertionCandidates( ) ); - candidates.push( - ...buildTextChangedCandidates( - snapshot.index, - snapshot.step.action, - preNodes, - postNodes, - actedTargetHint, - framePath, - candidateSource, - MAX_TEXT_CANDIDATES_PER_STEP - ) - ); + if (!suppressContentCandidates) { + candidates.push( + ...buildTextChangedCandidates( + snapshot.index, + snapshot.step.action, + preNodes, + postNodes, + actedTargetHint, + framePath, + candidateSource, + MAX_TEXT_CANDIDATES_PER_STEP + ) + ); + } - candidates.push( - ...buildStateChangeCandidates( - snapshot.index, - snapshot.step.action, - preNodes, - postNodes, - actedTargetHint, - framePath, - candidateSource - ) - ); + if (!suppressContentCandidates) { + candidates.push( + ...buildStateChangeCandidates( + snapshot.index, + snapshot.step.action, + preNodes, + postNodes, + actedTargetHint, + framePath, + candidateSource + ) + ); + } if (delta.length === 0) continue; - const textCandidates = buildTextCandidates( - snapshot.index, - snapshot.step.action, - delta, - actedTargetHint, - framePath, - candidateSource, - MAX_TEXT_CANDIDATES_PER_STEP - ); + const textCandidates = suppressContentCandidates + ? [] + : buildTextCandidates( + snapshot.index, + snapshot.step.action, + delta, + actedTargetHint, + framePath, + candidateSource, + MAX_TEXT_CANDIDATES_PER_STEP + ); candidates.push(...textCandidates); const textTargetValues = new Set( @@ -137,15 +149,17 @@ export function buildSnapshotAssertionCandidates( ) ); - const visibleCandidates = buildVisibleCandidates( - snapshot.index, - snapshot.step.action, - delta, - actedTargetHint, - framePath, - candidateSource, - MAX_VISIBLE_CANDIDATES_PER_STEP - ); + const visibleCandidates = suppressContentCandidates + ? [] + : buildVisibleCandidates( + snapshot.index, + snapshot.step.action, + delta, + actedTargetHint, + framePath, + candidateSource, + MAX_VISIBLE_CANDIDATES_PER_STEP + ); for (const visibleCandidate of visibleCandidates) { const visibleTarget = diff --git a/src/core/improve/assertion-candidates.test.ts b/src/core/improve/assertion-candidates.test.ts index 55ea1e8..fd0ff64 100644 --- a/src/core/improve/assertion-candidates.test.ts +++ b/src/core/improve/assertion-candidates.test.ts @@ -201,4 +201,44 @@ describe("buildAssertionCandidates", () => { "navigation-like dynamic click target" ); }); + + it("keeps deterministic fallback for stable exact link clicks", () => { + const out = buildAssertionCandidates( + [ + { + action: "click", + target: { + value: "getByRole('link', { name: 'Settings', exact: true })", + kind: "locatorExpression", + source: "manual", + }, + }, + ], + [] + ); + + expect(out.skippedNavigationLikeClicks).toHaveLength(0); + expect(out.candidates).toHaveLength(1); + expect(out.candidates[0]?.candidate.action).toBe("assertVisible"); + }); + + it("does not skip stable selectors with story/article in id-like values", () => { + const out = buildAssertionCandidates( + [ + { + action: "click", + target: { + value: "locator('#user-story-tab')", + kind: "locatorExpression", + source: "manual", + }, + }, + ], + [] + ); + + expect(out.skippedNavigationLikeClicks).toHaveLength(0); + expect(out.candidates).toHaveLength(1); + expect(out.candidates[0]?.candidate.action).toBe("assertVisible"); + }); }); diff --git a/src/core/improve/assertion-candidates.ts b/src/core/improve/assertion-candidates.ts index 11095e3..8039462 100644 --- a/src/core/improve/assertion-candidates.ts +++ b/src/core/improve/assertion-candidates.ts @@ -1,9 +1,6 @@ import type { AssertionCandidate, StepFinding } from "./report-schema.js"; import type { Step, Target } from "../yaml-schema.js"; -import { - assessTargetDynamics, - extractTargetTextFragments, -} from "./dynamic-target.js"; +import { classifyNavigationLikeInteraction } from "./navigation-like-interaction.js"; const COVERAGE_FALLBACK_CONFIDENCE = 0.76; @@ -123,32 +120,3 @@ function clamp01(value: number): number { if (value > 1) return 1; return value; } - -function classifyNavigationLikeInteraction(step: Step, target: Target): string | undefined { - if (step.action !== "click" && step.action !== "press" && step.action !== "hover") { - return undefined; - } - - const targetValue = target.value; - const isRoleLink = /getByRole\(\s*['"]link['"]/.test(targetValue); - const hasExact = /exact\s*:\s*true/.test(targetValue); - const hasContentCardPattern = - /headline|teaser|article|story|content[-_ ]?card|breaking[-_ ]?push|hero[-_ ]?card/i.test( - targetValue - ); - - const { dynamicSignals } = assessTargetDynamics(target); - const queryTexts = extractTargetTextFragments(target); - const hasHeadlineLikeText = - queryTexts.some((text) => text.length >= 48) || - dynamicSignals.includes("contains_headline_like_text") || - dynamicSignals.includes("contains_weather_or_news_fragment") || - dynamicSignals.includes("contains_pipe_separator") || - dynamicSignals.includes("contains_date_or_time_fragment"); - - if ((isRoleLink && hasHeadlineLikeText) || (isRoleLink && hasExact) || hasContentCardPattern) { - return "navigation-like dynamic click target"; - } - - return undefined; -} diff --git a/src/core/improve/navigation-like-interaction.ts b/src/core/improve/navigation-like-interaction.ts new file mode 100644 index 0000000..7d7930a --- /dev/null +++ b/src/core/improve/navigation-like-interaction.ts @@ -0,0 +1,39 @@ +import type { Step, Target } from "../yaml-schema.js"; +import { + assessTargetDynamics, + extractTargetTextFragments, +} from "./dynamic-target.js"; + +function isNavigationLikeAction(action: Step["action"]): boolean { + return action === "click" || action === "press" || action === "hover"; +} + +const CONTENT_CARD_TEXT_PATTERN = + /headline|teaser|article|story|content[-_ ]?card|breaking[-_ ]?push|hero[-_ ]?card/i; + +export function classifyNavigationLikeInteraction( + step: Step, + target: Target +): string | undefined { + if (!isNavigationLikeAction(step.action)) return undefined; + + const targetValue = target.value; + const isRoleLink = /getByRole\(\s*['"]link['"]/.test(targetValue); + const queryTexts = extractTargetTextFragments(target); + const hasContentCardPattern = queryTexts.some((text) => + CONTENT_CARD_TEXT_PATTERN.test(text) + ); + const { dynamicSignals } = assessTargetDynamics(target); + const hasHeadlineLikeText = + queryTexts.some((text) => text.length >= 48) || + dynamicSignals.includes("contains_headline_like_text") || + dynamicSignals.includes("contains_weather_or_news_fragment") || + dynamicSignals.includes("contains_pipe_separator") || + dynamicSignals.includes("contains_date_or_time_fragment"); + + if ((isRoleLink && hasHeadlineLikeText) || hasContentCardPattern) { + return "navigation-like dynamic click target"; + } + + return undefined; +}