feat: capture state and element evidence

This commit is contained in:
赵义仑
2026-06-16 19:15:52 +08:00
parent 1324f1882c
commit 581724ea42
6 changed files with 431 additions and 4 deletions

View File

@@ -1,7 +1,15 @@
import type { Page } from "playwright"; import type { Page } from "playwright";
import { shortHash, stableHash } from "./hash.js"; import { shortHash, stableHash } from "./hash.js";
import { generateLocators, verifyLocators } from "./locator.js"; import { generateLocators, verifyLocators } from "./locator.js";
import type { ActionableElement, RawActionableElement, SnapshotResult, StateNode } from "./types.js"; import type {
ActionableElement,
ContextAnchor,
ElementContext,
ParameterSurface,
RawActionableElement,
SnapshotResult,
StateNode,
} from "./types.js";
export async function snapshotPage(page: Page, openedByEdgeId: string | null = null): Promise<SnapshotResult> { export async function snapshotPage(page: Page, openedByEdgeId: string | null = null): Promise<SnapshotResult> {
const raw = await page.evaluate(collectPageSnapshot); const raw = await page.evaluate(collectPageSnapshot);
@@ -13,6 +21,7 @@ export async function snapshotPage(page: Page, openedByEdgeId: string | null = n
visibleTextHash: stableHash(raw.visibleText), visibleTextHash: stableHash(raw.visibleText),
actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)), actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
}); });
const viewport = raw.viewport;
const state: StateNode = { const state: StateNode = {
stateId: `state_${shortHash(stateHash)}`, stateId: `state_${shortHash(stateHash)}`,
@@ -27,11 +36,38 @@ export async function snapshotPage(page: Page, openedByEdgeId: string | null = n
modalStack: raw.modalStack, modalStack: raw.modalStack,
openedByEdgeId, openedByEdgeId,
createdAt, createdAt,
lifecycle: "fingerprinted",
urlCanonicalKey: raw.urlCanonicalKey,
language: raw.language,
viewport,
evidence: [
{ level: "observed", source: "browser.location", description: "URL and route captured from the page" },
{
level: "derived",
source: "snapshot.hash",
description: "State hashes derived from DOM, visible text, and actionable signatures",
},
],
}; };
const elements: ActionableElement[] = []; const elements: ActionableElement[] = [];
for (const rawElement of raw.elements) { for (const rawElement of raw.elements) {
const elementId = `el_${shortHash([state.stateId, rawElement.uid, actionableSignature(rawElement)])}`; const elementId = `el_${shortHash([
state.stateId,
rawElement.candidateIndex,
rawElement.tag,
rawElement.role,
rawElement.attributes["data-testid"] || rawElement.attributes["data-test-id"] || rawElement.attributes["data-test"] || null,
rawElement.attributes.id || null,
rawElement.attributes.name || null,
rawElement.identityText,
rawElement.context.dialog?.name ?? null,
rawElement.context.form?.name ?? null,
rawElement.context.section?.name ?? null,
rawElement.context.landmark?.name ?? null,
rawElement.context.listitem?.name ?? null,
null,
])}`;
const locators = await verifyLocators(page, generateLocators(elementId, rawElement)); const locators = await verifyLocators(page, generateLocators(elementId, rawElement));
elements.push({ elements.push({
elementId, elementId,
@@ -48,18 +84,30 @@ export async function snapshotPage(page: Page, openedByEdgeId: string | null = n
shadowPath: [], shadowPath: [],
interactability: rawElement.visibility, interactability: rawElement.visibility,
locators, locators,
context: rawElement.context,
parameterSurface: rawElement.parameterSurface,
evidence: rawElement.evidence,
}); });
} }
return { state, elements, visibleText: raw.visibleText }; return { state, elements, visibleText: raw.visibleText };
} }
function boundedIdentityText(value: string | null | undefined): string | null {
const normalized = value?.replace(/\s+/g, " ").trim() ?? "";
if (!normalized || normalized.length > 120) return null;
return normalized;
}
interface BrowserSnapshot { interface BrowserSnapshot {
url: string; url: string;
route: string; route: string;
title: string; title: string;
dom: string; dom: string;
visibleText: string; visibleText: string;
language: string | null;
viewport: { width: number; height: number };
urlCanonicalKey: string;
modalStack: string[]; modalStack: string[];
elements: RawActionableElement[]; elements: RawActionableElement[];
} }
@@ -102,6 +150,9 @@ function collectPageSnapshot(): BrowserSnapshot {
title: document.title, title: document.title,
dom: document.documentElement.outerHTML, dom: document.documentElement.outerHTML,
visibleText: normalizeText(document.body?.innerText || ""), visibleText: normalizeText(document.body?.innerText || ""),
language: normalizeNullable(document.documentElement.lang || navigator.language || ""),
viewport: { width: window.innerWidth, height: window.innerHeight },
urlCanonicalKey: canonicalUrl(location.href),
modalStack: Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']")) modalStack: Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']"))
.filter((element) => isVisible(element)) .filter((element) => isVisible(element))
.map((element) => getAccessibleName(element) || element.id || element.tagName.toLowerCase()), .map((element) => getAccessibleName(element) || element.id || element.tagName.toLowerCase()),
@@ -125,15 +176,28 @@ function collectPageSnapshot(): BrowserSnapshot {
const label = getLabel(element); const label = getLabel(element);
const placeholder = "placeholder" in element ? normalizeNullable(String((element as HTMLInputElement).placeholder || "")) : null; const placeholder = "placeholder" in element ? normalizeNullable(String((element as HTMLInputElement).placeholder || "")) : null;
const accessibleName = normalizeNullable(getAccessibleName(element) || label || placeholder || text); const accessibleName = normalizeNullable(getAccessibleName(element) || label || placeholder || text);
const context = getElementContext(element);
const parameterSurface = getParameterSurface(element);
const identityText = getElementIdentityText(element, label, placeholder, text, accessibleName);
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
const topAtCenter = document.elementFromPoint(centerX, centerY); const topAtCenter = document.elementFromPoint(centerX, centerY);
return { return {
uid: `${tag}_${index}_${attributes.id || attributes.name || accessibleName || text || ""}`, uid: `${tag}_${index}_${
attributes.id ||
attributes.name ||
attributes["data-testid"] ||
attributes["data-test-id"] ||
attributes["data-test"] ||
identityText ||
""
}`,
candidateIndex: index,
tag, tag,
role, role,
accessibleName, accessibleName,
identityText,
text: normalizeNullable(text), text: normalizeNullable(text),
label, label,
placeholder, placeholder,
@@ -150,6 +214,132 @@ function collectPageSnapshot(): BrowserSnapshot {
editable: isEditable(element), editable: isEditable(element),
receivesEvents: topAtCenter === element || Boolean(topAtCenter && element.contains(topAtCenter)), receivesEvents: topAtCenter === element || Boolean(topAtCenter && element.contains(topAtCenter)),
}, },
context,
parameterSurface,
evidence: [
{ level: "observed", source: "dom", description: "Element was visible and matched actionable selector" },
{
level: "derived",
source: "accessibility",
description: "Role, name, label, and placeholder were normalized from DOM evidence",
},
],
};
}
function getElementContext(element: HTMLElement): ElementContext {
return {
dialog: anchor(element.closest<HTMLElement>("[role='dialog'], dialog[open], [aria-modal='true']"), "dialog"),
form: anchor(element.closest<HTMLElement>("form"), "form"),
section: anchor(element.closest<HTMLElement>("section, article"), "section"),
row: anchor(element.closest<HTMLElement>("tr, [role='row']"), "row"),
landmark: anchor(
element.closest<HTMLElement>("nav, aside, header, footer, main, [role='navigation'], [role='main'], [role='complementary']"),
"landmark",
),
listitem: anchor(element.closest<HTMLElement>("li, [role='listitem']"), "listitem"),
};
}
function anchor(element: HTMLElement | null, kind: ContextAnchor["kind"]): ContextAnchor | null {
if (!element) return null;
return {
kind,
name: getAnchorName(element),
text: normalizeNullable((element.innerText || element.textContent || "").slice(0, 240)),
};
}
function getAnchorName(element: HTMLElement): string | null {
return (
boundedAnchorText(element.getAttribute("aria-label")) ??
getAriaLabelledByAnchorName(element) ??
boundedAnchorText(element.getAttribute("title")) ??
boundedAnchorText(element.id) ??
getHeadingAnchorName(element)
);
}
function getAriaLabelledByAnchorName(element: HTMLElement): string | null {
const labelledBy = element.getAttribute("aria-labelledby");
if (!labelledBy) return null;
const text = labelledBy
.split(/\s+/)
.map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "")
.join(" ");
return boundedAnchorText(text);
}
function getHeadingAnchorName(element: HTMLElement): string | null {
const heading = element.querySelector<HTMLElement>("h1, h2, h3, h4, h5, h6, [role='heading']");
if (!heading) return null;
return boundedAnchorText(heading.innerText || heading.textContent || "");
}
function boundedAnchorText(value: string | null | undefined): string | null {
const normalized = normalizeText(value || "");
if (!normalized || normalized.length > 120) return null;
return normalized;
}
function boundedIdentityText(value: string | null | undefined): string | null {
const normalized = normalizeText(value || "");
if (!normalized || normalized.length > 120) return null;
return normalized;
}
function getElementIdentityText(
element: HTMLElement,
label: string | null,
placeholder: string | null,
text: string,
accessibleName: string | null,
): string | null {
return (
boundedIdentityText(element.getAttribute("aria-label")) ??
getAriaLabelledByAnchorName(element) ??
boundedIdentityText(element.getAttribute("title")) ??
boundedIdentityText(label) ??
boundedIdentityText(placeholder) ??
(isContainerLike(element) ? null : boundedIdentityText(accessibleName) ?? boundedIdentityText(text))
);
}
function isContainerLike(element: HTMLElement): boolean {
const tag = element.tagName.toLowerCase();
const role = element.getAttribute("role")?.toLowerCase() || "";
return (
["article", "aside", "dialog", "div", "footer", "form", "header", "li", "main", "nav", "ol", "section", "table", "tbody", "thead", "tr", "ul"].includes(
tag,
) ||
["complementary", "dialog", "form", "grid", "list", "listitem", "main", "navigation", "region", "row", "table"].includes(role)
);
}
function getParameterSurface(element: HTMLElement): ParameterSurface | null {
const tag = element.tagName.toLowerCase();
if (!["input", "select", "textarea"].includes(tag) && !element.isContentEditable) return null;
const input = element as HTMLInputElement;
const select = element as HTMLSelectElement;
return {
name: input.name || element.getAttribute("name"),
inputType: tag === "input" ? (input.type || "text").toLowerCase() : tag,
required: Boolean((input as HTMLInputElement).required),
readonly: Boolean((input as HTMLInputElement).readOnly),
disabled: Boolean((input as HTMLInputElement).disabled),
pattern: input.getAttribute("pattern"),
min: input.getAttribute("min"),
max: input.getAttribute("max"),
maxLength: Number.isFinite(input.maxLength) && input.maxLength >= 0 ? input.maxLength : null,
options:
tag === "select"
? Array.from(select.options).map((option) => ({
label: normalizeText(option.label || option.textContent || ""),
value: option.value,
disabled: option.disabled,
selected: option.selected,
}))
: [],
}; };
} }
@@ -247,5 +437,15 @@ function collectPageSnapshot(): BrowserSnapshot {
const normalized = normalizeText(value); const normalized = normalizeText(value);
return normalized || null; return normalized || null;
} }
}
function canonicalUrl(value: string): string {
const url = new URL(value);
url.hash = "";
const params = Array.from(url.searchParams.entries())
.filter(([key]) => !/^utm_|^fbclid$|^gclid$/.test(key))
.sort(([left], [right]) => left.localeCompare(right));
url.search = "";
for (const [key, item] of params) url.searchParams.append(key, item);
return url.toString();
}
}

View File

@@ -11,6 +11,34 @@ export interface EvidenceRecord {
description: string; description: string;
} }
export interface ContextAnchor {
kind: "dialog" | "form" | "section" | "row" | "landmark" | "listitem";
name: string | null;
text: string | null;
}
export interface ElementContext {
dialog: ContextAnchor | null;
form: ContextAnchor | null;
section: ContextAnchor | null;
row: ContextAnchor | null;
landmark: ContextAnchor | null;
listitem: ContextAnchor | null;
}
export interface ParameterSurface {
name: string | null;
inputType: string | null;
required: boolean;
readonly: boolean;
disabled: boolean;
pattern: string | null;
min: string | null;
max: string | null;
maxLength: number | null;
options: Array<{ label: string; value: string; disabled: boolean; selected: boolean }>;
}
export interface RuntimeContext { export interface RuntimeContext {
browserName: "chromium"; browserName: "chromium";
viewport: { width: number; height: number }; viewport: { width: number; height: number };
@@ -73,6 +101,9 @@ export interface ActionableElement {
shadowPath: string[]; shadowPath: string[];
interactability: VisibilityInfo; interactability: VisibilityInfo;
locators: LocatorCandidate[]; locators: LocatorCandidate[];
context: ElementContext;
parameterSurface: ParameterSurface | null;
evidence: EvidenceRecord[];
} }
export interface StateNode { export interface StateNode {
@@ -88,6 +119,11 @@ export interface StateNode {
modalStack: string[]; modalStack: string[];
openedByEdgeId: string | null; openedByEdgeId: string | null;
createdAt: string; createdAt: string;
lifecycle: StateLifecycle;
urlCanonicalKey: string;
language: string | null;
viewport: { width: number; height: number };
evidence: EvidenceRecord[];
} }
export type ActionType = "click" | "fill" | "select" | "check" | "uncheck" | "navigate"; export type ActionType = "click" | "fill" | "select" | "check" | "uncheck" | "navigate";
@@ -194,15 +230,20 @@ export interface FailedObservation {
export interface RawActionableElement { export interface RawActionableElement {
uid: string; uid: string;
candidateIndex: number;
tag: string; tag: string;
role: string | null; role: string | null;
accessibleName: string | null; accessibleName: string | null;
identityText: string | null;
text: string | null; text: string | null;
label: string | null; label: string | null;
placeholder: string | null; placeholder: string | null;
attributes: Record<string, string>; attributes: Record<string, string>;
bbox: BoundingBox | null; bbox: BoundingBox | null;
visibility: VisibilityInfo; visibility: VisibilityInfo;
context: ElementContext;
parameterSurface: ParameterSurface | null;
evidence: EvidenceRecord[];
} }
export interface SnapshotResult { export interface SnapshotResult {

View File

@@ -58,6 +58,42 @@
<button type="submit">Submit search</button> <button type="submit">Submit search</button>
</form> </form>
<form>
<p>
This unnamed form deliberately contains a long body of descendant text that should remain available as bounded
context evidence but must not be promoted into the anchor name used for element identity. It describes internal
notes, operator workflow details, historical context, and extra explanatory copy that would make a noisy and
unstable identifier if copied into the context anchor name.
</p>
<label>
Unbounded Probe
<input name="unboundedProbe" />
</label>
</form>
<main aria-label="Operations">
<button data-testid="main-action">Main action</button>
</main>
<section aria-label="Duplicate controls">
<button>Duplicate</button>
<button>Duplicate</button>
</section>
<section role="dialog" aria-modal="true" class="open">
<span>Short volatile copy</span>
<button data-testid="short-dialog-action">Short dialog action</button>
</section>
<section role="dialog" aria-modal="true" class="open">
<p>
This unnamed dialog carries noisy descendant copy about multi-step operator notes, escalation background,
support annotations, internal timestamps, and other volatile details that must stay out of the dialog anchor
name while remaining available as bounded context text evidence for nearby actionable controls.
</p>
<button data-testid="dialog-probe">Dialog probe</button>
</section>
<button disabled data-testid="disabled-open">Open disabled panel</button> <button disabled data-testid="disabled-open">Open disabled panel</button>
<button data-testid="open-settings" onclick="document.getElementById('settings-dialog').classList.add('open')">Open settings</button> <button data-testid="open-settings" onclick="document.getElementById('settings-dialog').classList.add('open')">Open settings</button>
<section id="settings-dialog" role="dialog" aria-modal="true" aria-label="Settings dialog"> <section id="settings-dialog" role="dialog" aria-modal="true" aria-label="Settings dialog">

View File

@@ -3,9 +3,12 @@ import { test } from "node:test";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url";
import { exploreUrl, scanUrl } from "../src/engine.js"; import { exploreUrl, scanUrl } from "../src/engine.js";
import { shortHash } from "../src/hash.js";
const fixturePath = path.resolve("tests/fixtures/context-risk.html"); const fixturePath = path.resolve("tests/fixtures/context-risk.html");
const fixtureFileUrl = pathToFileURL(fixturePath).href;
async function withTempOutput<T>(name: string, callback: (out: string) => Promise<T>): Promise<T> { async function withTempOutput<T>(name: string, callback: (out: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(tmpdir(), "action-topology-engine-test-")); const dir = await mkdtemp(path.join(tmpdir(), "action-topology-engine-test-"));
@@ -51,6 +54,131 @@ test("graph metadata records generation context and policy versions", async () =
assert.equal(typeof graph.metadata.inputFingerprint, "string"); assert.equal(typeof graph.metadata.inputFingerprint, "string");
}); });
test("scan records state lifecycle, language, context anchors, and parameter surfaces", async () => {
const graph = await withTempOutput("evidence", (out) =>
scanUrl({ url: `${fixtureFileUrl}?utm_source=codex&gclid=ad-click&keep=1#customers`, out }),
);
const state = graph.states[0];
assert.equal(state.lifecycle, "fingerprinted");
assert.equal(state.language, "en");
assert.equal(state.viewport.width, 1440);
assert.equal(state.urlCanonicalKey, `${fixtureFileUrl}?keep=1`);
assert.equal(state.evidence.some((item) => item.level === "observed" && item.source === "browser.location"), true);
assert.equal(state.evidence.some((item) => item.level === "derived" && item.source === "snapshot.hash"), true);
const query = graph.elements.find((element) => element.accessibleName === "Query");
assert.ok(query);
assert.equal(query.parameterSurface?.name, "q");
assert.equal(query.parameterSurface?.required, true);
assert.equal(query.parameterSurface?.maxLength, 40);
assert.equal(query.context.form?.name, "Search customers");
assert.equal(query.evidence.some((item) => item.level === "observed"), true);
const dialogProbe = graph.elements.find((element) => element.accessibleName === "Dialog probe");
assert.ok(dialogProbe);
assert.equal(dialogProbe.context.dialog?.name, null);
assert.equal(dialogProbe.context.dialog?.text?.includes("noisy descendant copy"), true);
const shortUnnamedDialog = graph.elements.find(
(element) => element.tag === "section" && element.role === "dialog" && element.text?.includes("Short volatile copy"),
);
assert.ok(shortUnnamedDialog);
assert.equal(shortUnnamedDialog.context.dialog?.name, null);
assert.equal(shortUnnamedDialog.context.dialog?.text?.includes("Short volatile copy"), true);
const shortUnnamedDialogIndex = graph.elements.findIndex((element) => element === shortUnnamedDialog);
assert.notEqual(shortUnnamedDialogIndex, -1);
assert.equal(
shortUnnamedDialog.elementId,
`el_${shortHash([
state.stateId,
shortUnnamedDialogIndex,
shortUnnamedDialog.tag,
shortUnnamedDialog.role,
null,
null,
null,
null,
shortUnnamedDialog.context.dialog?.name,
shortUnnamedDialog.context.form?.name,
shortUnnamedDialog.context.section?.name,
shortUnnamedDialog.context.landmark?.name,
shortUnnamedDialog.context.listitem?.name,
null,
])}`,
);
const unnamedDialog = graph.elements.find(
(element) => element.tag === "section" && element.role === "dialog" && element.text?.includes("noisy descendant copy"),
);
assert.ok(unnamedDialog);
const unnamedDialogIndex = graph.elements.findIndex((element) => element === unnamedDialog);
assert.notEqual(unnamedDialogIndex, -1);
assert.equal(
unnamedDialog.elementId,
`el_${shortHash([
state.stateId,
unnamedDialogIndex,
unnamedDialog.tag,
unnamedDialog.role,
null,
null,
null,
null,
unnamedDialog.context.dialog?.name,
unnamedDialog.context.form?.name,
unnamedDialog.context.section?.name,
unnamedDialog.context.landmark?.name,
unnamedDialog.context.listitem?.name,
null,
])}`,
);
const duplicates = graph.elements.filter((element) => element.accessibleName === "Duplicate");
assert.equal(duplicates.length, 2);
assert.notEqual(duplicates[0]?.elementId, duplicates[1]?.elementId);
const unboundedProbe = graph.elements.find((element) => element.accessibleName === "Unbounded Probe");
assert.ok(unboundedProbe);
assert.equal(unboundedProbe.context.form?.name, null);
assert.equal(unboundedProbe.context.form?.text?.includes("long body of descendant text"), true);
const mainAction = graph.elements.find((element) => element.accessibleName === "Main action");
assert.ok(mainAction);
assert.equal(mainAction.context.section, null);
assert.equal(mainAction.context.landmark?.name, "Operations");
const edit = graph.elements.find((element) => element.accessibleName === "Edit");
assert.ok(edit);
assert.equal(edit.context.row?.text.includes("Acme Co") || edit.context.row?.text.includes("Beta LLC"), true);
const edits = graph.elements.filter((element) => element.accessibleName === "Edit");
assert.equal(edits.length, 2);
assert.notEqual(edits[0]?.elementId, edits[1]?.elementId);
assert.notEqual(edits[0]?.context.row?.text, edits[1]?.context.row?.text);
for (const item of edits) {
const index = graph.elements.findIndex((element) => element === item);
assert.notEqual(index, -1);
assert.equal(
item.elementId,
`el_${shortHash([
state.stateId,
index,
item.tag,
item.role,
item.attributes["data-testid"] || item.attributes["data-test-id"] || item.attributes["data-test"] || null,
item.attributes.id || null,
item.attributes.name || null,
item.accessibleName,
item.context.dialog?.name,
item.context.form?.name,
item.context.section?.name,
item.context.landmark?.name,
item.context.listitem?.name,
null,
])}`,
);
}
});
test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => { test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => {
const graph = await withTempOutput("explore", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out })); const graph = await withTempOutput("explore", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true); assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true);

View File

@@ -6,15 +6,27 @@ import type { RawActionableElement } from "../src/types.js";
function raw(overrides: Partial<RawActionableElement>): RawActionableElement { function raw(overrides: Partial<RawActionableElement>): RawActionableElement {
return { return {
uid: "button_0_Open settings", uid: "button_0_Open settings",
candidateIndex: 0,
tag: "button", tag: "button",
role: "button", role: "button",
accessibleName: "Open settings", accessibleName: "Open settings",
identityText: "Open settings",
text: "Open settings", text: "Open settings",
label: null, label: null,
placeholder: null, placeholder: null,
attributes: { "data-testid": "open-settings", id: "openSettings" }, attributes: { "data-testid": "open-settings", id: "openSettings" },
bbox: { x: 0, y: 0, width: 100, height: 30 }, bbox: { x: 0, y: 0, width: 100, height: 30 },
visibility: { visible: true, enabled: true, editable: false, receivesEvents: true }, visibility: { visible: true, enabled: true, editable: false, receivesEvents: true },
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
...overrides, ...overrides,
}; };
} }

View File

@@ -19,6 +19,16 @@ function element(overrides: Partial<ActionableElement>): ActionableElement {
shadowPath: [], shadowPath: [],
interactability: { visible: true, enabled: true, editable: false, receivesEvents: true }, interactability: { visible: true, enabled: true, editable: false, receivesEvents: true },
locators: [], locators: [],
context: {
dialog: null,
form: null,
section: null,
row: null,
landmark: null,
listitem: null,
},
parameterSurface: null,
evidence: [],
...overrides, ...overrides,
}; };
} }