diff --git a/src/locator.ts b/src/locator.ts index 4808d0c..02ed7a8 100644 --- a/src/locator.ts +++ b/src/locator.ts @@ -1,9 +1,9 @@ import type { Locator, Page } from "playwright"; import { shortHash } from "./hash.js"; -import type { LocatorCandidate, LocatorDescriptor, RawActionableElement } from "./types.js"; +import type { LocatorCandidate, LocatorDescriptor, LocatorScopeDescriptor, RawActionableElement } from "./types.js"; export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] { - const candidates: Array> = []; + const candidates: Array> = []; const name = clean(raw.accessibleName); const label = clean(raw.label); const placeholder = clean(raw.placeholder); @@ -11,6 +11,14 @@ export function generateLocators(elementId: string, raw: RawActionableElement): const testId = raw.attributes["data-testid"] || raw.attributes["data-test-id"] || raw.attributes["data-test"]; const id = raw.attributes.id; const nameAttr = raw.attributes.name; + const scopes = contextScopes(raw); + const evidence = [ + { + level: "derived" as const, + source: "locator.generator", + description: "Locator candidate generated from state-local element semantics", + }, + ]; if (raw.role && name) { candidates.push({ @@ -20,6 +28,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`, score: 0.95, reason: "role plus accessible name", + scopes, + evidence, }); } @@ -31,6 +41,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.getByTestId(${JSON.stringify(testId)})`, score: 0.92, reason: "explicit test id", + scopes, + evidence, }); } @@ -42,6 +54,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.getByLabel(${JSON.stringify(label)})`, score: 0.9, reason: "associated label", + scopes, + evidence, }); } @@ -53,6 +67,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`, score: 0.86, reason: "placeholder text", + scopes, + evidence, }); } @@ -64,6 +80,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.getByText(${JSON.stringify(text)}, { exact: true })`, score: 0.75, reason: "visible text fallback", + scopes, + evidence, }); } @@ -75,6 +93,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.locator(${JSON.stringify(`#${cssEscape(id)}`)})`, score: 0.72, reason: "id selector fallback", + scopes, + evidence, }); } @@ -86,6 +106,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement): expression: `page.locator(${JSON.stringify(`${raw.tag}[name="${cssString(nameAttr)}"]`)})`, score: 0.68, reason: "name attribute fallback", + scopes, + evidence, }); } @@ -94,23 +116,126 @@ export function generateLocators(elementId: string, raw: RawActionableElement): locatorId: `loc_${shortHash([elementId, candidate.descriptor])}`, elementId, verified: false, + identity: null, failureCount: 0, })); } -export async function verifyLocators(page: Page, locators: LocatorCandidate[]): Promise { +export async function verifyLocators(page: Page, raw: RawActionableElement, locators: LocatorCandidate[]): Promise { const verifiedAt = new Date().toISOString(); const verified: LocatorCandidate[] = []; for (const locator of locators) { try { - const matchCount = await resolveLocator(page, locator.descriptor).count(); + const resolved = resolveLocator(page, locator.descriptor); + const matchCount = await resolved.count(); + const identity = + matchCount === 1 + ? await resolved.first().evaluate( + (element, expected) => { + const html = element as HTMLElement; + const tag = html.tagName.toLowerCase(); + const text = normalizeNullable(html.innerText || html.textContent || ""); + const label = getLabel(html); + const placeholder = "placeholder" in html ? normalizeNullable(String((html as HTMLInputElement).placeholder || "")) : null; + const accessibleName = normalizeNullable(getAccessibleName(html) || label || placeholder || text || ""); + const inferredRole = html.getAttribute("role") || inferRole(html); + const visible = isVisible(html); + return { + sameTag: tag === expected.tag, + sameRole: expected.role ? inferredRole === expected.role : true, + sameAccessibleName: expected.accessibleName ? accessibleName === expected.accessibleName : true, + sameText: expected.text ? text === expected.text : true, + sameVisibility: visible === expected.visible, + }; + + function inferRole(item: HTMLElement): string | null { + const itemTag = item.tagName.toLowerCase(); + if (itemTag === "button") return "button"; + if (itemTag === "a" && item.hasAttribute("href")) return "link"; + if (itemTag === "select") return "combobox"; + if (itemTag === "textarea") return "textbox"; + if (itemTag === "input") { + const type = ((item as HTMLInputElement).type || "text").toLowerCase(); + if (["button", "submit", "reset"].includes(type)) return "button"; + if (type === "checkbox") return "checkbox"; + if (type === "radio") return "radio"; + return "textbox"; + } + return null; + } + + function getAccessibleName(item: HTMLElement): string | null { + const ariaLabel = item.getAttribute("aria-label"); + if (ariaLabel) return normalizeNullable(ariaLabel); + const labelledBy = item.getAttribute("aria-labelledby"); + if (labelledBy) { + const labelledText = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "") + .join(" "); + if (labelledText.trim()) return normalizeNullable(labelledText); + } + const title = item.getAttribute("title"); + if (title) return normalizeNullable(title); + const alt = item.getAttribute("alt"); + if (alt) return normalizeNullable(alt); + return normalizeNullable(item.innerText || item.textContent || ""); + } + + function getLabel(item: HTMLElement): string | null { + const id = item.getAttribute("id"); + if (id) { + const explicit = document.querySelector(`label[for="${CSS.escape(id)}"]`); + if (explicit) return normalizeNullable(explicit.innerText || explicit.textContent || ""); + } + const implicit = item.closest("label"); + if (implicit) return normalizeNullable(implicit.innerText || implicit.textContent || ""); + return null; + } + + function isVisible(item: HTMLElement): boolean { + const style = getComputedStyle(item); + if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false; + const rect = item.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function normalizeText(value: string): string { + return value.replace(/\s+/g, " ").trim(); + } + + function normalizeNullable(value: string): string | null { + const normalized = normalizeText(value); + return normalized || null; + } + }, + { + tag: raw.tag, + role: raw.role, + accessibleName: raw.accessibleName, + text: raw.text, + visible: raw.visibility.visible, + }, + ) + : null; + const identityMatches = identity ? Object.values(identity).every(Boolean) : false; + const locatorVerified = matchCount === 1 && identityMatches; verified.push({ ...locator, - verified: matchCount === 1, + verified: locatorVerified, matchCount, lastVerifiedAt: verifiedAt, - score: matchCount === 1 ? locator.score : Math.max(0.1, locator.score - 0.35), + score: locatorVerified ? locator.score : Math.max(0.1, locator.score - (matchCount === 1 ? 0.2 : 0.35)), + identity, + evidence: [ + ...locator.evidence, + { + level: locatorVerified ? "verified" : "observed", + source: "locator.verify", + description: `Locator matched ${matchCount} element(s)`, + }, + ], }); } catch { verified.push({ @@ -119,6 +244,11 @@ export async function verifyLocators(page: Page, locators: LocatorCandidate[]): matchCount: 0, lastVerifiedAt: verifiedAt, score: Math.max(0.1, locator.score - 0.45), + identity: null, + evidence: [ + ...locator.evidence, + { level: "observed", source: "locator.verify", description: "Locator verification failed before matching elements" }, + ], failureCount: locator.failureCount + 1, }); } @@ -155,6 +285,13 @@ function dedupeCandidates(candidates: T[]): T[ return deduped; } +function contextScopes(raw: RawActionableElement): LocatorScopeDescriptor[] { + const contexts = [raw.context.dialog, raw.context.row, raw.context.form, raw.context.section, raw.context.landmark, raw.context.listitem]; + return contexts + .filter((context): context is LocatorScopeDescriptor => Boolean(context && (context.name || context.text))) + .map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null })); +} + function clean(value: string | null | undefined): string | undefined { const cleaned = value?.replace(/\s+/g, " ").trim(); return cleaned || undefined; @@ -167,4 +304,3 @@ function cssEscape(value: string): string { function cssString(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } - diff --git a/src/snapshot.ts b/src/snapshot.ts index 8219f3a..491e1af 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -68,7 +68,7 @@ export async function snapshotPage(page: Page, openedByEdgeId: string | null = n rawElement.context.listitem?.name ?? null, null, ])}`; - const locators = await verifyLocators(page, generateLocators(elementId, rawElement)); + const locators = await verifyLocators(page, rawElement, generateLocators(elementId, rawElement)); elements.push({ elementId, stateId: state.stateId, diff --git a/src/types.ts b/src/types.ts index 52196ca..0c287e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,20 @@ export type LocatorDescriptor = export type LocatorFramework = "playwright" | "css"; +export interface LocatorScopeDescriptor { + kind: ContextAnchor["kind"]; + name: string | null; + text: string | null; +} + +export interface LocatorIdentityEvidence { + sameTag: boolean; + sameRole: boolean; + sameAccessibleName: boolean; + sameText: boolean; + sameVisibility: boolean; +} + export interface LocatorCandidate { locatorId: string; elementId: string; @@ -69,6 +83,9 @@ export interface LocatorCandidate { verified: boolean; lastVerifiedAt?: string; matchCount?: number; + scopes: LocatorScopeDescriptor[]; + identity: LocatorIdentityEvidence | null; + evidence: EvidenceRecord[]; failureCount: number; } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index b931ddc..9037c14 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -40,6 +40,16 @@ test("scan captures a state-local inventory without clicking high-risk actions", assert.equal(graph.elements.some((element) => element.accessibleName === "Delete account"), false); }); +test("duplicated row actions are verified with semantic locator evidence", async () => { + const graph = await withTempOutput("duplicate-locators", (out) => scanUrl({ url: fixturePath, out })); + const editButtons = graph.elements.filter((element) => element.accessibleName === "Edit"); + assert.equal(editButtons.length, 2); + for (const edit of editButtons) { + assert.equal(edit.locators.some((locator) => locator.verified && locator.identity?.sameAccessibleName), true); + assert.equal(edit.locators.some((locator) => locator.scopes.some((scope) => scope.kind === "row")), true); + } +}); + test("graph metadata records generation context and policy versions", async () => { const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out })); assert.equal(graph.schemaVersion, "0.2"); diff --git a/tests/locator.test.ts b/tests/locator.test.ts index cb06835..d398dbd 100644 --- a/tests/locator.test.ts +++ b/tests/locator.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { generateLocators } from "../src/locator.js"; +import { chromium, type Page } from "playwright"; +import { generateLocators, verifyLocators } from "../src/locator.js"; import type { RawActionableElement } from "../src/types.js"; function raw(overrides: Partial): RawActionableElement { @@ -31,6 +32,17 @@ function raw(overrides: Partial): RawActionableElement { }; } +async function withPage(html: string, callback: (page: Page) => Promise): Promise { + const browser = await chromium.launch(); + const page = await browser.newPage(); + try { + await page.setContent(html); + return await callback(page); + } finally { + await browser.close(); + } +} + test("locator generation prioritizes user-facing Playwright locators before CSS fallbacks", () => { const candidates = generateLocators("el_test", raw({})); assert.equal(candidates[0].strategy, "role"); @@ -55,3 +67,100 @@ test("locator generation keeps CSS as fallback for id and name", () => { assert.equal(candidates.some((candidate) => candidate.expression.includes("#queryInput")), true); assert.equal(candidates.some((candidate) => candidate.expression.includes("[name=")), true); }); + +test("locator candidates carry context-scope evidence for duplicated actions", () => { + const candidates = generateLocators( + "el_edit", + raw({ + uid: "button_4_Edit", + tag: "button", + role: "button", + accessibleName: "Edit", + text: "Edit", + attributes: {}, + context: { + dialog: null, + form: null, + section: { kind: "section", name: "Customers", text: "Customers Acme Co Active Edit" }, + row: { kind: "row", name: null, text: "Acme Co Active Edit" }, + landmark: null, + listitem: null, + }, + }), + ); + assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true); +}); + +test("placeholder-only input locator verifies identity from raw placeholder evidence", async () => { + await withPage(``, async (page) => { + const rawElement = raw({ + uid: "input_0_Search customers", + tag: "input", + role: "textbox", + accessibleName: "Search customers", + identityText: "Search customers", + text: null, + label: null, + placeholder: "Search customers", + attributes: {}, + visibility: { visible: true, enabled: true, editable: true, receivesEvents: true }, + }); + const placeholder = generateLocators("el_placeholder", rawElement).find((candidate) => candidate.strategy === "placeholder"); + assert.ok(placeholder); + + const [verified] = await verifyLocators(page, rawElement, [placeholder]); + + assert.equal(verified.verified, true); + assert.equal(verified.matchCount, 1); + assert.equal(verified.identity?.sameAccessibleName, true); + }); +}); + +test("label-derived input locator verifies identity from raw label evidence", async () => { + await withPage(``, async (page) => { + const rawElement = raw({ + uid: "input_0_Query", + tag: "input", + role: "textbox", + accessibleName: "Query", + identityText: "Query", + text: null, + label: "Query", + placeholder: null, + attributes: { id: "query" }, + visibility: { visible: true, enabled: true, editable: true, receivesEvents: true }, + }); + const label = generateLocators("el_label", rawElement).find((candidate) => candidate.strategy === "label"); + assert.ok(label); + + const [verified] = await verifyLocators(page, rawElement, [label]); + + assert.equal(verified.verified, true); + assert.equal(verified.matchCount, 1); + assert.equal(verified.identity?.sameAccessibleName, true); + }); +}); + +test("duplicate Edit locators are unverified and omit non-unique identity evidence", async () => { + await withPage(``, async (page) => { + const rawElement = raw({ + uid: "button_0_Edit", + tag: "button", + role: "button", + accessibleName: "Edit", + identityText: "Edit", + text: "Edit", + attributes: {}, + }); + const duplicateLocators = generateLocators("el_edit", rawElement).filter((candidate) => ["role", "text"].includes(candidate.strategy)); + assert.equal(duplicateLocators.length, 2); + + const verified = await verifyLocators(page, rawElement, duplicateLocators); + + for (const locator of verified) { + assert.equal(locator.verified, false); + assert.equal(locator.matchCount, 2); + assert.equal(locator.identity, null); + } + }); +});