feat: capture state and element evidence
This commit is contained in:
208
src/snapshot.ts
208
src/snapshot.ts
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
41
src/types.ts
41
src/types.ts
@@ -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 {
|
||||||
|
|||||||
36
tests/fixtures/context-risk.html
vendored
36
tests/fixtures/context-risk.html
vendored
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user