Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/services/record-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
1 change: 1 addition & 0 deletions src/app/services/record-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export async function runRecord(opts: RecordCliOptions): Promise<void> {
applySelectors: true,
applyAssertions: true,
assertions: "candidates",
assertionPolicy: "reliable",
});

const summary = improveResult.report.summary;
Expand Down
72 changes: 72 additions & 0 deletions src/core/improve/assertion-candidates-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
100 changes: 57 additions & 43 deletions src/core/improve/assertion-candidates-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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 =
Expand Down
40 changes: 40 additions & 0 deletions src/core/improve/assertion-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
34 changes: 1 addition & 33 deletions src/core/improve/assertion-candidates.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
}
39 changes: 39 additions & 0 deletions src/core/improve/navigation-like-interaction.ts
Original file line number Diff line number Diff line change
@@ -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;
}