feat: verify locators with context evidence
This commit is contained in:
150
src/locator.ts
150
src/locator.ts
@@ -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, '\\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
17
src/types.ts
17
src/types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user