feat: verify locators with context evidence

This commit is contained in:
赵义仑
2026-06-16 20:02:25 +08:00
parent 581724ea42
commit c41f260e51
5 changed files with 281 additions and 9 deletions

View File

@@ -1,9 +1,9 @@
import type { Locator, Page } from "playwright"; import type { Locator, Page } from "playwright";
import { shortHash } from "./hash.js"; 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[] { export function generateLocators(elementId: string, raw: RawActionableElement): LocatorCandidate[] {
const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount">> = []; const candidates: Array<Omit<LocatorCandidate, "locatorId" | "elementId" | "verified" | "failureCount" | "identity">> = [];
const name = clean(raw.accessibleName); const name = clean(raw.accessibleName);
const label = clean(raw.label); const label = clean(raw.label);
const placeholder = clean(raw.placeholder); 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 testId = raw.attributes["data-testid"] || raw.attributes["data-test-id"] || raw.attributes["data-test"];
const id = raw.attributes.id; const id = raw.attributes.id;
const nameAttr = raw.attributes.name; 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) { if (raw.role && name) {
candidates.push({ candidates.push({
@@ -20,6 +28,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`, expression: `page.getByRole(${JSON.stringify(raw.role)}, { name: ${JSON.stringify(name)} })`,
score: 0.95, score: 0.95,
reason: "role plus accessible name", reason: "role plus accessible name",
scopes,
evidence,
}); });
} }
@@ -31,6 +41,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.getByTestId(${JSON.stringify(testId)})`, expression: `page.getByTestId(${JSON.stringify(testId)})`,
score: 0.92, score: 0.92,
reason: "explicit test id", reason: "explicit test id",
scopes,
evidence,
}); });
} }
@@ -42,6 +54,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.getByLabel(${JSON.stringify(label)})`, expression: `page.getByLabel(${JSON.stringify(label)})`,
score: 0.9, score: 0.9,
reason: "associated label", reason: "associated label",
scopes,
evidence,
}); });
} }
@@ -53,6 +67,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`, expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`,
score: 0.86, score: 0.86,
reason: "placeholder text", reason: "placeholder text",
scopes,
evidence,
}); });
} }
@@ -64,6 +80,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.getByText(${JSON.stringify(text)}, { exact: true })`, expression: `page.getByText(${JSON.stringify(text)}, { exact: true })`,
score: 0.75, score: 0.75,
reason: "visible text fallback", reason: "visible text fallback",
scopes,
evidence,
}); });
} }
@@ -75,6 +93,8 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
expression: `page.locator(${JSON.stringify(`#${cssEscape(id)}`)})`, expression: `page.locator(${JSON.stringify(`#${cssEscape(id)}`)})`,
score: 0.72, score: 0.72,
reason: "id selector fallback", 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)}"]`)})`, expression: `page.locator(${JSON.stringify(`${raw.tag}[name="${cssString(nameAttr)}"]`)})`,
score: 0.68, score: 0.68,
reason: "name attribute fallback", reason: "name attribute fallback",
scopes,
evidence,
}); });
} }
@@ -94,23 +116,126 @@ export function generateLocators(elementId: string, raw: RawActionableElement):
locatorId: `loc_${shortHash([elementId, candidate.descriptor])}`, locatorId: `loc_${shortHash([elementId, candidate.descriptor])}`,
elementId, elementId,
verified: false, verified: false,
identity: null,
failureCount: 0, failureCount: 0,
})); }));
} }
export async function verifyLocators(page: Page, locators: LocatorCandidate[]): Promise<LocatorCandidate[]> { export async function verifyLocators(page: Page, raw: RawActionableElement, locators: LocatorCandidate[]): Promise<LocatorCandidate[]> {
const verifiedAt = new Date().toISOString(); const verifiedAt = new Date().toISOString();
const verified: LocatorCandidate[] = []; const verified: LocatorCandidate[] = [];
for (const locator of locators) { for (const locator of locators) {
try { 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<HTMLLabelElement>(`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({ verified.push({
...locator, ...locator,
verified: matchCount === 1, verified: locatorVerified,
matchCount, matchCount,
lastVerifiedAt: verifiedAt, 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 { } catch {
verified.push({ verified.push({
@@ -119,6 +244,11 @@ export async function verifyLocators(page: Page, locators: LocatorCandidate[]):
matchCount: 0, matchCount: 0,
lastVerifiedAt: verifiedAt, lastVerifiedAt: verifiedAt,
score: Math.max(0.1, locator.score - 0.45), 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, failureCount: locator.failureCount + 1,
}); });
} }
@@ -155,6 +285,13 @@ function dedupeCandidates<T extends { expression: string }>(candidates: T[]): T[
return deduped; 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 { function clean(value: string | null | undefined): string | undefined {
const cleaned = value?.replace(/\s+/g, " ").trim(); const cleaned = value?.replace(/\s+/g, " ").trim();
return cleaned || undefined; return cleaned || undefined;
@@ -167,4 +304,3 @@ function cssEscape(value: string): string {
function cssString(value: string): string { function cssString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
} }

View File

@@ -68,7 +68,7 @@ export async function snapshotPage(page: Page, openedByEdgeId: string | null = n
rawElement.context.listitem?.name ?? null, rawElement.context.listitem?.name ?? null,
null, null,
])}`; ])}`;
const locators = await verifyLocators(page, generateLocators(elementId, rawElement)); const locators = await verifyLocators(page, rawElement, generateLocators(elementId, rawElement));
elements.push({ elements.push({
elementId, elementId,
stateId: state.stateId, stateId: state.stateId,

View File

@@ -57,6 +57,20 @@ export type LocatorDescriptor =
export type LocatorFramework = "playwright" | "css"; 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 { export interface LocatorCandidate {
locatorId: string; locatorId: string;
elementId: string; elementId: string;
@@ -69,6 +83,9 @@ export interface LocatorCandidate {
verified: boolean; verified: boolean;
lastVerifiedAt?: string; lastVerifiedAt?: string;
matchCount?: number; matchCount?: number;
scopes: LocatorScopeDescriptor[];
identity: LocatorIdentityEvidence | null;
evidence: EvidenceRecord[];
failureCount: number; failureCount: number;
} }

View File

@@ -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); 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 () => { test("graph metadata records generation context and policy versions", async () => {
const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out })); const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out }));
assert.equal(graph.schemaVersion, "0.2"); assert.equal(graph.schemaVersion, "0.2");

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; 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"; import type { RawActionableElement } from "../src/types.js";
function raw(overrides: Partial<RawActionableElement>): RawActionableElement { function raw(overrides: Partial<RawActionableElement>): RawActionableElement {
@@ -31,6 +32,17 @@ function raw(overrides: Partial<RawActionableElement>): RawActionableElement {
}; };
} }
async function withPage<T>(html: string, callback: (page: Page) => Promise<T>): Promise<T> {
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", () => { test("locator generation prioritizes user-facing Playwright locators before CSS fallbacks", () => {
const candidates = generateLocators("el_test", raw({})); const candidates = generateLocators("el_test", raw({}));
assert.equal(candidates[0].strategy, "role"); 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("#queryInput")), true);
assert.equal(candidates.some((candidate) => candidate.expression.includes("[name=")), 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(`<input placeholder="Search customers" />`, 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(`<label for="query">Query</label><input id="query" />`, 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(`<button>Edit</button><button>Edit</button>`, 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);
}
});
});