singularity-forge/src/resources/extensions/browser-tools/core.ts
Jeremy McSpadden 6a46c9df1a fix: resolve browser-tools TypeScript type errors in CI
Fix type compatibility issues introduced in the JS→TS conversion:
- Restore PageEntry.page to `any` (holds Playwright Page instance)
- Use Record<string, any> for session parameters in
  buildFailureHypothesis and summarizeBrowserSession (callers pass
  rich objects with extra properties)
- Use Record<string, unknown> for formatTimelineEntries options
- Add explicit type annotations to local variables and callbacks
  to satisfy noImplicitAny in tsconfig.extensions.json
2026-03-16 13:39:14 -05:00

1196 lines
40 KiB
TypeScript

/**
* Runtime-neutral helper logic for browser-tools.
*
* Kept free of pi-specific imports so it can be exercised with node:test.
*/
// ---------------------------------------------------------------------------
// Interfaces & Types
// ---------------------------------------------------------------------------
export interface ActionTimeline {
limit: number;
nextId: number;
entries: ActionEntry[];
}
export interface ActionEntry {
id: number;
tool: string;
paramsSummary: string;
startedAt: number;
finishedAt: number | null;
status: string;
beforeUrl: string;
afterUrl: string;
verificationSummary?: string;
warningSummary?: string;
diffSummary?: string;
changed?: boolean;
error?: string;
}
export interface ActionPartial {
tool: string;
paramsSummary?: string;
startedAt?: number;
beforeUrl?: string;
afterUrl?: string;
verificationSummary?: string;
warningSummary?: string;
diffSummary?: string;
changed?: boolean;
error?: string;
}
export interface ActionUpdates {
finishedAt?: number;
status?: string;
afterUrl?: string;
verificationSummary?: string;
warningSummary?: string;
diffSummary?: string;
changed?: boolean;
error?: string;
}
export interface DiffResult {
changed: boolean;
changes: Array<{ type: string; before: unknown; after: unknown }>;
summary: string;
}
export interface Threshold {
op: string;
n: number;
}
export interface PageRegistry {
pages: PageEntry[];
activePageId: number | null;
nextId: number;
}
export interface PageEntry {
id: number;
page: any;
title: string;
url: string;
opener: number | null;
}
export interface PageListEntry {
id: number;
title: string;
url: string;
opener: number | null;
isActive: boolean;
}
export interface SnapshotModeConfig {
tags: string[];
roles: string[];
selectors: string[];
ariaAttributes: string[];
useInteractiveFilter: boolean;
visibleOnly?: boolean;
containerExpand?: boolean;
}
export interface AssertionCheckResult {
name: string;
passed: boolean;
actual: unknown;
expected: unknown;
selector?: string;
text?: string;
}
export interface AssertionEvaluation {
verified: boolean;
checks: AssertionCheckResult[];
summary: string;
agentHint: string;
}
export interface WaitValidationError {
error: string;
}
export interface BatchStepResult {
ok: boolean;
stopReason: string | null;
failedStepIndex: number | null;
stepResults: unknown[];
summary: string;
}
export interface FormattedTimeline {
entries: Array<{
id: number | null;
tool: string;
status: string;
durationMs: number | null;
beforeUrl: string;
afterUrl: string;
line: string;
}>;
retained: number;
totalRecorded: number;
bounded: boolean;
summary: string;
}
export interface FailureHypothesis {
hasFailures: boolean;
categories: string[];
summary: string;
signals: Array<{ category: string; source: string; detail: string }>;
}
export interface SessionSummary {
counts: {
pages: number;
actions: { total: number; retained: number; success: number; error: number; running: number };
waits: { total: number; success: number; error: number; running: number };
assertions: { total: number; passed: number; failed: number; running: number };
consoleErrors: number;
failedRequests: number;
dialogs: number;
};
activePage: { id: number | null; title: string; url: string } | null;
caveats: string[];
failureHypothesis: FailureHypothesis;
summary: string;
}
// ---------------------------------------------------------------------------
// Action Timeline
// ---------------------------------------------------------------------------
export function createActionTimeline(limit = 60): ActionTimeline {
return {
limit,
nextId: 1,
entries: [],
};
}
export function beginAction(timeline: ActionTimeline, partial: ActionPartial): ActionEntry {
const entry: ActionEntry = {
id: timeline.nextId++,
tool: partial.tool,
paramsSummary: partial.paramsSummary ?? "",
startedAt: partial.startedAt ?? Date.now(),
finishedAt: null,
status: "running",
beforeUrl: partial.beforeUrl ?? "",
afterUrl: partial.afterUrl ?? "",
verificationSummary: partial.verificationSummary,
warningSummary: partial.warningSummary,
diffSummary: partial.diffSummary,
changed: partial.changed,
error: partial.error,
};
timeline.entries.push(entry);
if (timeline.entries.length > timeline.limit) {
timeline.entries.splice(0, timeline.entries.length - timeline.limit);
}
return entry;
}
export function finishAction(timeline: ActionTimeline, actionId: number, updates: ActionUpdates = {}): ActionEntry | null {
const entry = timeline.entries.find((item) => item.id === actionId);
if (!entry) return null;
Object.assign(entry, updates, {
finishedAt: updates.finishedAt ?? Date.now(),
status: updates.status ?? entry.status ?? "success",
afterUrl: updates.afterUrl ?? entry.afterUrl ?? "",
verificationSummary: updates.verificationSummary ?? entry.verificationSummary,
warningSummary: updates.warningSummary ?? entry.warningSummary,
diffSummary: updates.diffSummary ?? entry.diffSummary,
changed: updates.changed ?? entry.changed,
error: updates.error ?? entry.error,
});
return entry;
}
export function findAction(timeline: ActionTimeline, actionId: number): ActionEntry | null {
return timeline.entries.find((item) => item.id === actionId) ?? null;
}
export function toActionParamsSummary(params: unknown): string {
if (!params || typeof params !== "object") return "";
const entries: string[] = [];
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
if (value === undefined || value === null) continue;
if (typeof value === "string") {
entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`);
continue;
}
if (Array.isArray(value)) {
entries.push(`${key}=[${value.length}]`);
continue;
}
if (typeof value === "object") {
entries.push(`${key}={...}`);
continue;
}
entries.push(`${key}=${String(value)}`);
}
return entries.slice(0, 6).join(", ");
}
// ---------------------------------------------------------------------------
// Compact State Diffing
// ---------------------------------------------------------------------------
interface CompactStateForDiff {
url?: string;
title?: string;
focus?: string;
dialog?: { count?: number; title?: string };
counts?: Record<string, number>;
headings?: string[];
bodyText?: string;
}
export function diffCompactStates(before: CompactStateForDiff | null | undefined, after: CompactStateForDiff | null | undefined): DiffResult {
const changes: Array<{ type: string; before: unknown; after: unknown }> = [];
if (!before || !after) {
return {
changed: false,
changes: [],
summary: "Diff unavailable",
};
}
if (before.url !== after.url) {
changes.push({ type: "url", before: before.url, after: after.url });
}
if (before.title !== after.title) {
changes.push({ type: "title", before: before.title, after: after.title });
}
if (before.focus !== after.focus) {
changes.push({ type: "focus", before: before.focus, after: after.focus });
}
if ((before.dialog?.count ?? 0) !== (after.dialog?.count ?? 0)) {
changes.push({
type: "dialog_count",
before: before.dialog?.count ?? 0,
after: after.dialog?.count ?? 0,
});
}
if ((before.dialog?.title ?? "") !== (after.dialog?.title ?? "")) {
changes.push({
type: "dialog_title",
before: before.dialog?.title ?? "",
after: after.dialog?.title ?? "",
});
}
for (const key of ["landmarks", "buttons", "links", "inputs"]) {
const beforeValue = before.counts?.[key] ?? 0;
const afterValue = after.counts?.[key] ?? 0;
if (beforeValue !== afterValue) {
changes.push({ type: `count:${key}`, before: beforeValue, after: afterValue });
}
}
const beforeHeadings = JSON.stringify(before.headings ?? []);
const afterHeadings = JSON.stringify(after.headings ?? []);
if (beforeHeadings !== afterHeadings) {
changes.push({
type: "headings",
before: before.headings ?? [],
after: after.headings ?? [],
});
}
const beforeBody = before.bodyText ?? "";
const afterBody = after.bodyText ?? "";
if (beforeBody !== afterBody) {
changes.push({
type: "body_text",
before: beforeBody.slice(0, 120),
after: afterBody.slice(0, 120),
});
}
const changed = changes.length > 0;
const summary = changed
? changes
.slice(0, 4)
.map((change) => {
if (change.type === "url") return `URL changed to ${change.after}`;
if (change.type === "title") return `title changed to ${change.after}`;
if (change.type === "focus") return `focus changed`;
if (change.type === "dialog_count") return `dialog count ${change.before}${change.after}`;
if (change.type.startsWith("count:")) return `${change.type.slice(6)} ${change.before}${change.after}`;
if (change.type === "headings") return "headings changed";
if (change.type === "body_text") return "visible text changed";
return `${change.type} changed`;
})
.join("; ")
: "No meaningful browser-state change detected";
return { changed, changes, summary };
}
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
function normalizeString(value: unknown): string {
return String(value ?? "").trim();
}
export function includesNeedle(haystack: string, needle: string): boolean {
return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase());
}
// ---------------------------------------------------------------------------
// Threshold parsing for count-based assertions
// ---------------------------------------------------------------------------
/**
* Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">=").
*/
export function parseThreshold(value: string | null | undefined): Threshold | null {
if (value == null) return null;
const str = String(value).trim();
if (str === "") return null;
const match = str.match(/^(>=|<=|==|>|<)?\s*(\d+)$/);
if (!match) return null;
const op = match[1] || ">=";
const n = parseInt(match[2], 10);
return { op, n };
}
/**
* Evaluate whether a count meets a parsed threshold.
*/
export function meetsThreshold(count: number, threshold: Threshold): boolean {
switch (threshold.op) {
case ">=": return count >= threshold.n;
case "<=": return count <= threshold.n;
case "==": return count === threshold.n;
case ">": return count > threshold.n;
case "<": return count < threshold.n;
default: return false;
}
}
/**
* Filter entries that occurred at or after a given action's start time.
* If sinceActionId is missing or the action isn't found, returns all entries.
*/
export function getEntriesSince(
entries: Array<{ timestamp?: number }>,
sinceActionId: number | undefined,
timeline: ActionTimeline,
): Array<{ timestamp?: number }> {
if (!entries || !Array.isArray(entries)) return [];
if (sinceActionId == null || !timeline) return entries;
const action = findAction(timeline, sinceActionId);
if (!action) return entries;
const since = action.startedAt;
return entries.filter((e) => (e.timestamp ?? 0) >= since);
}
// ---------------------------------------------------------------------------
// Assertion Evaluation
// ---------------------------------------------------------------------------
interface AssertionCheckInput {
kind: string;
selector?: string;
value?: string;
text?: string;
checked?: boolean;
sinceActionId?: number;
}
interface AssertionState {
url?: string;
title?: string;
bodyText?: string;
focus?: string;
selectorStates?: Record<string, { visible?: boolean; value?: string; checked?: boolean | null }>;
consoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>;
networkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>;
allConsoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>;
allNetworkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>;
actionTimeline?: ActionTimeline | null;
}
export function evaluateAssertionChecks({ checks, state }: { checks: AssertionCheckInput[]; state: AssertionState }): AssertionEvaluation {
const results: AssertionCheckResult[] = [];
const selectorStates = state.selectorStates ?? {};
const consoleEntries = state.consoleEntries ?? [];
const networkEntries = state.networkEntries ?? [];
const allConsoleEntries = state.allConsoleEntries ?? state.consoleEntries ?? [];
const allNetworkEntries = state.allNetworkEntries ?? state.networkEntries ?? [];
const actionTimeline = state.actionTimeline ?? null;
for (const check of checks) {
const selectorState = check.selector ? selectorStates[check.selector] ?? null : null;
let passed = false;
let actual: unknown;
let expected: unknown;
switch (check.kind) {
case "url_contains":
actual = state.url ?? "";
expected = check.value ?? "";
passed = includesNeedle(actual as string, expected as string);
break;
case "title_contains":
actual = state.title ?? "";
expected = check.value ?? "";
passed = includesNeedle(actual as string, expected as string);
break;
case "text_visible":
actual = state.bodyText ?? "";
expected = check.text ?? "";
passed = includesNeedle(actual as string, expected as string);
break;
case "text_not_visible":
actual = state.bodyText ?? "";
expected = check.text ?? "";
passed = !includesNeedle(actual as string, expected as string);
break;
case "selector_visible":
actual = selectorState?.visible ?? false;
expected = true;
passed = actual === true;
break;
case "selector_hidden":
actual = selectorState?.visible ?? false;
expected = false;
passed = actual === false;
break;
case "value_equals":
actual = selectorState?.value ?? "";
expected = check.value ?? "";
passed = actual === expected;
break;
case "value_contains":
actual = selectorState?.value ?? "";
expected = check.value ?? "";
passed = includesNeedle(actual as string, expected as string);
break;
case "focused_matches":
actual = state.focus ?? "";
expected = check.value ?? "";
passed = includesNeedle(actual as string, expected as string);
break;
case "checked_equals":
actual = selectorState?.checked ?? null;
expected = !!check.checked;
passed = actual === expected;
break;
case "no_console_errors":
actual = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror").length;
expected = 0;
passed = actual === 0;
break;
case "no_failed_requests":
actual = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)).length;
expected = 0;
passed = actual === 0;
break;
// --- S02: New structured network/console assertion kinds ---
case "request_url_seen": {
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
actual = matches.length > 0;
expected = true;
passed = actual === true;
break;
}
case "response_status": {
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
const statusNum = parseInt(check.value!, 10);
const matches = (filtered as typeof allNetworkEntries).filter(
(e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum
);
actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`;
expected = `status=${check.value ?? ""}`;
passed = matches.length > 0;
break;
}
case "console_message_matches": {
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
actual = matches.length > 0;
expected = true;
passed = actual === true;
break;
}
case "network_count": {
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
const threshold = parseThreshold(check.value);
if (!threshold) {
actual = `invalid threshold: ${check.value}`;
expected = check.value ?? "";
passed = false;
} else {
actual = `count=${matches.length}`;
expected = `${threshold.op}${threshold.n}`;
passed = meetsThreshold(matches.length, threshold);
}
break;
}
case "console_count": {
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
const threshold = parseThreshold(check.value);
if (!threshold) {
actual = `invalid threshold: ${check.value}`;
expected = check.value ?? "";
passed = false;
} else {
actual = `count=${matches.length}`;
expected = `${threshold.op}${threshold.n}`;
passed = meetsThreshold(matches.length, threshold);
}
break;
}
case "no_console_errors_since": {
const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!);
const errors = (filtered as typeof allConsoleEntries).filter((e) => e.type === "error" || e.type === "pageerror");
actual = errors.length;
expected = 0;
passed = errors.length === 0;
break;
}
case "no_failed_requests_since": {
const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!);
const failures = (filtered as typeof allNetworkEntries).filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400));
actual = failures.length;
expected = 0;
passed = failures.length === 0;
break;
}
default:
actual = "unsupported";
expected = check.kind;
passed = false;
break;
}
results.push({
name: check.kind,
passed,
actual,
expected,
selector: check.selector,
text: check.text,
});
}
const failed = results.filter((result) => !result.passed);
const verified = failed.length === 0;
return {
verified,
checks: results,
summary: verified
? `PASS (${results.length}/${results.length} checks)`
: `FAIL (${failed.length}/${results.length} checks failed)`,
agentHint: verified
? "All assertion checks passed"
: failed[0]
? `Investigate ${failed[0].name} (expected ${JSON.stringify(failed[0].expected)}, got ${JSON.stringify(failed[0].actual)})`
: "Assertion failed",
};
}
// ---------------------------------------------------------------------------
// Wait-condition validation
// ---------------------------------------------------------------------------
interface WaitConditionSpec {
needsValue: boolean;
valueLabel: string;
needsThreshold?: boolean;
}
/**
* All recognized wait conditions with their parameter requirements.
*/
const WAIT_CONDITIONS: Record<string, WaitConditionSpec> = {
// Existing 5 conditions
selector_visible: { needsValue: true, valueLabel: "CSS selector" },
selector_hidden: { needsValue: true, valueLabel: "CSS selector" },
url_contains: { needsValue: true, valueLabel: "URL substring" },
network_idle: { needsValue: false, valueLabel: "" },
delay: { needsValue: true, valueLabel: "milliseconds as a string (e.g. '1000')" },
// New 6 conditions (S03)
text_visible: { needsValue: true, valueLabel: "text to search for" },
text_hidden: { needsValue: true, valueLabel: "text to search for" },
request_completed: { needsValue: true, valueLabel: "URL substring to match" },
console_message: { needsValue: true, valueLabel: "message substring to match" },
element_count: { needsValue: true, valueLabel: "CSS selector", needsThreshold: true },
region_stable: { needsValue: true, valueLabel: "CSS selector" },
};
/**
* Validate parameters for a browser_wait_for condition.
*/
export function validateWaitParams(params: { condition: string; value?: string; threshold?: string }): WaitValidationError | null {
const { condition, value, threshold } = params ?? {};
if (!condition) {
return { error: "condition is required" };
}
const spec = WAIT_CONDITIONS[condition];
if (!spec) {
const known = Object.keys(WAIT_CONDITIONS).join(", ");
return { error: `unknown condition "${condition}". Known conditions: ${known}` };
}
if (spec.needsValue && (!value || String(value).trim() === "")) {
return { error: `${condition} requires a value (${spec.valueLabel})` };
}
if (spec.needsThreshold && threshold != null && String(threshold).trim() !== "") {
const parsed = parseThreshold(threshold);
if (!parsed) {
return { error: `${condition} threshold is malformed: "${threshold}". Expected format: >=N, <=N, ==N, >N, <N, or bare N` };
}
}
return null;
}
// ---------------------------------------------------------------------------
// Region-stable script generator
// ---------------------------------------------------------------------------
/**
* Generate a JS expression string for page.waitForFunction() that detects
* DOM stability by comparing snapshot hashes across polling intervals.
*/
export function createRegionStableScript(selector: string): string {
// Create a stable key from the selector (simple hash to avoid special chars)
const safeKey = Array.from(selector).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0;
const windowKey = `__pw_region_stable_${safeKey}`;
return `(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
const snapshot = el.innerHTML.length + '|' + el.childElementCount + '|' + el.innerText.length;
const prev = window[${JSON.stringify(windowKey)}];
window[${JSON.stringify(windowKey)}] = snapshot;
if (prev === undefined) return false;
return snapshot === prev;
})()`;
}
// ---------------------------------------------------------------------------
// Page Registry — pure-logic operations for multi-page/tab management
// ---------------------------------------------------------------------------
export function createPageRegistry(): PageRegistry {
return { pages: [], activePageId: null, nextId: 1 };
}
export function registryAddPage(
registry: PageRegistry,
{ page, title = "", url = "", opener = null }: { page: unknown; title?: string; url?: string; opener?: number | null },
): PageEntry {
const entry: PageEntry = { id: registry.nextId++, page, title, url, opener };
registry.pages.push(entry);
return entry;
}
export function registryRemovePage(registry: PageRegistry, pageId: number): { removed: PageEntry; newActiveId: number | null } {
const idx = registry.pages.findIndex((p) => p.id === pageId);
if (idx === -1) {
const available = registry.pages.map((p) => p.id);
throw new Error(
`registryRemovePage: page ${pageId} not found. ` +
`Available page IDs: [${available.join(", ")}]. ` +
`Registry size: ${registry.pages.length}.`
);
}
const [removed] = registry.pages.splice(idx, 1);
// Orphan any pages whose opener was the removed page
for (const entry of registry.pages) {
if (entry.opener === pageId) {
entry.opener = null;
}
}
let newActiveId = registry.activePageId;
if (registry.activePageId === pageId) {
if (registry.pages.length === 0) {
newActiveId = null;
} else if (removed.opener !== null && registry.pages.some((p) => p.id === removed.opener)) {
newActiveId = removed.opener;
} else {
newActiveId = registry.pages[registry.pages.length - 1].id;
}
registry.activePageId = newActiveId;
}
return { removed, newActiveId };
}
export function registrySetActive(registry: PageRegistry, pageId: number): void {
const entry = registry.pages.find((p) => p.id === pageId);
if (!entry) {
const available = registry.pages.map((p) => p.id);
throw new Error(
`registrySetActive: page ${pageId} not found. ` +
`Available page IDs: [${available.join(", ")}]. ` +
`Registry size: ${registry.pages.length}.`
);
}
registry.activePageId = pageId;
}
export function registryGetActive(registry: PageRegistry): PageEntry {
if (registry.activePageId === null) {
throw new Error(
`registryGetActive: no active page. ` +
`Registry contains ${registry.pages.length} page(s). ` +
`Page IDs: [${registry.pages.map((p) => p.id).join(", ")}].`
);
}
const entry = registry.pages.find((p) => p.id === registry.activePageId);
if (!entry) {
throw new Error(
`registryGetActive: activePageId ${registry.activePageId} not found in registry. ` +
`Available page IDs: [${registry.pages.map((p) => p.id).join(", ")}]. ` +
`Registry size: ${registry.pages.length}. This indicates stale state.`
);
}
return entry;
}
export function registryGetPage(registry: PageRegistry, pageId: number): PageEntry | null {
return registry.pages.find((p) => p.id === pageId) ?? null;
}
export function registryListPages(registry: PageRegistry): PageListEntry[] {
return registry.pages.map((entry) => ({
id: entry.id,
title: entry.title,
url: entry.url,
opener: entry.opener,
isActive: entry.id === registry.activePageId,
}));
}
// ---------------------------------------------------------------------------
// FIFO Bounded Log Pusher
// ---------------------------------------------------------------------------
export function createBoundedLogPusher(maxSize: number): (array: unknown[], entry: unknown) => void {
return function push(array: unknown[], entry: unknown): void {
array.push(entry);
if (array.length > maxSize) {
array.splice(0, array.length - maxSize);
}
};
}
export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }: {
steps: unknown[];
executeStep: (step: unknown, index: number) => Promise<{ ok: boolean; [key: string]: unknown }>;
stopOnFailure?: boolean;
}): Promise<BatchStepResult> {
const results: unknown[] = [];
for (let i = 0; i < steps.length; i += 1) {
const step = steps[i] as { action: string };
const result = await executeStep(step, i);
results.push(result);
if (result.ok === false && stopOnFailure) {
return {
ok: false,
stopReason: "step_failed",
failedStepIndex: i,
stepResults: results,
summary: `Stopped at step ${i + 1} (${step.action})`,
};
}
}
return {
ok: true,
stopReason: null,
failedStepIndex: null,
stepResults: results,
summary: `Completed ${results.length} step(s)`,
};
}
// ---------------------------------------------------------------------------
// Snapshot Modes — semantic element filtering for browser_snapshot_refs
// ---------------------------------------------------------------------------
export const SNAPSHOT_MODES: Record<string, SnapshotModeConfig> = {
interactive: {
tags: [],
roles: [],
selectors: [],
ariaAttributes: [],
useInteractiveFilter: true,
},
form: {
tags: ["input", "select", "textarea", "button", "fieldset", "label", "output", "datalist"],
roles: ["textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "slider", "spinbutton", "listbox", "option"],
selectors: ["[contenteditable]"],
ariaAttributes: [],
useInteractiveFilter: false,
},
dialog: {
tags: ["dialog"],
roles: ["dialog", "alertdialog"],
selectors: ['[role="dialog"]', '[role="alertdialog"]'],
ariaAttributes: [],
useInteractiveFilter: false,
containerExpand: true,
},
navigation: {
tags: ["a", "nav"],
roles: ["link", "navigation", "menubar", "menu", "menuitem"],
selectors: [],
ariaAttributes: [],
useInteractiveFilter: false,
},
errors: {
tags: [],
roles: ["alert", "status"],
selectors: ['[aria-invalid="true"]', '[role="alert"]', '[role="status"]'],
ariaAttributes: ["aria-invalid", "aria-errormessage"],
useInteractiveFilter: false,
containerExpand: true,
},
headings: {
tags: ["h1", "h2", "h3", "h4", "h5", "h6"],
roles: ["heading"],
selectors: [],
ariaAttributes: [],
useInteractiveFilter: false,
},
visible_only: {
tags: [],
roles: [],
selectors: [],
ariaAttributes: [],
useInteractiveFilter: false,
visibleOnly: true,
},
};
export function getSnapshotModeConfig(mode: string): SnapshotModeConfig | null {
return SNAPSHOT_MODES[mode] ?? null;
}
// ---------------------------------------------------------------------------
// Fingerprint functions — structural identity for ref resolution
// ---------------------------------------------------------------------------
export function computeContentHash(text: string): string {
if (!text) return "0";
let h = 5381;
for (let i = 0; i < text.length; i++) {
h = ((h << 5) - h + text.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(16);
}
export function computeStructuralSignature(tag: string, role: string, childTags: string[]): string {
const input = `${tag}|${role}|${childTags.join(",")}`;
let h = 5381;
for (let i = 0; i < input.length; i++) {
h = ((h << 5) - h + input.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(16);
}
export function matchFingerprint(
stored: { contentHash?: string; structuralSignature?: string },
candidate: { contentHash?: string; structuralSignature?: string },
): boolean {
if (!stored || !candidate) return false;
if (!stored.contentHash || !stored.structuralSignature) return false;
if (!candidate.contentHash || !candidate.structuralSignature) return false;
return stored.contentHash === candidate.contentHash &&
stored.structuralSignature === candidate.structuralSignature;
}
// ---------------------------------------------------------------------------
// Timeline Formatting
// ---------------------------------------------------------------------------
function formatDurationMs(entry: { startedAt?: number; finishedAt?: number | null }): number | null {
const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null;
const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null;
if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null;
return finishedAt - startedAt;
}
function summarizeActionStatus(status: string | undefined): string {
if (status === "error") return "error";
if (status === "running") return "running";
return "success";
}
function looksBoundedWarning(value: unknown): boolean {
return /bounded .*history/i.test(String(value ?? ""));
}
function uniqueStrings(values: (string | undefined)[]): string[] {
return [...new Set(values.filter(Boolean))] as string[];
}
export function formatTimelineEntries(entries: ActionEntry[] = [], options: Record<string, unknown> = {}): FormattedTimeline {
const retained = (options.retained as number) ?? entries.length;
const totalRecorded = (options.totalRecorded as number) ?? retained;
const bounded = totalRecorded > retained;
if (!entries.length) {
return {
entries: [],
retained,
totalRecorded,
bounded,
summary: "No browser actions recorded.",
};
}
const formattedEntries = entries.map((entry) => {
const status = summarizeActionStatus(entry.status);
const durationMs = formatDurationMs(entry);
const parts: string[] = [
`#${entry.id ?? "?"}`,
entry.tool ?? "unknown_tool",
status,
];
if (durationMs != null) parts.push(`${durationMs}ms`);
if (entry.paramsSummary) parts.push(entry.paramsSummary);
if (entry.error) parts.push(entry.error);
if (entry.verificationSummary) parts.push(entry.verificationSummary);
if (entry.diffSummary) parts.push(entry.diffSummary);
if (entry.warningSummary) parts.push(entry.warningSummary);
return {
id: entry.id ?? null,
tool: entry.tool ?? "",
status,
durationMs,
beforeUrl: entry.beforeUrl ?? "",
afterUrl: entry.afterUrl ?? "",
line: parts.join(" | "),
};
});
const summary = bounded
? `Timeline: showing ${retained} of ${totalRecorded} recorded browser actions; older actions were discarded due to bounded history.`
: `Timeline: ${retained} browser action${retained === 1 ? "" : "s"} recorded.`;
return {
entries: formattedEntries,
retained,
totalRecorded,
bounded,
summary,
};
}
// ---------------------------------------------------------------------------
// Failure Hypothesis
// ---------------------------------------------------------------------------
export function buildFailureHypothesis(session: Record<string, any> = {}): FailureHypothesis {
const timelineEntries = session.actionTimeline?.entries ?? [];
const consoleEntries = session.consoleEntries ?? [];
const networkEntries = session.networkEntries ?? [];
const dialogEntries = session.dialogEntries ?? [];
const signals: Array<{ category: string; source: string; detail: string }> = [];
for (const entry of timelineEntries) {
if (entry?.status !== "error") continue;
if (entry.tool === "browser_wait_for") {
signals.push({
category: "wait",
source: `action#${entry.id ?? "?"}`,
detail: entry.error || entry.warningSummary || "Wait condition failed",
});
continue;
}
if (entry.tool === "browser_assert") {
signals.push({
category: "assert",
source: `action#${entry.id ?? "?"}`,
detail: entry.error || entry.verificationSummary || "Assertion failed",
});
continue;
}
signals.push({
category: "action",
source: `action#${entry.id ?? "?"}`,
detail: entry.error || `${entry.tool ?? "browser action"} failed`,
});
}
for (const entry of consoleEntries) {
if (entry?.type !== "error" && entry?.type !== "pageerror") continue;
signals.push({
category: "console",
source: entry.type!,
detail: entry.text || "Console error recorded",
});
}
for (const entry of networkEntries) {
const failed = entry?.failed || (typeof entry?.status === "number" && entry.status >= 400);
if (!failed) continue;
signals.push({
category: "network",
source: entry.url || "network request",
detail: `${entry.url || "request"} failed${typeof entry?.status === "number" ? ` with ${entry.status}` : ""}`,
});
}
for (const entry of dialogEntries) {
signals.push({
category: "dialog",
source: entry?.type || "dialog",
detail: entry?.message || "Dialog appeared during failure investigation",
});
}
const categories = uniqueStrings(signals.map((signal) => signal.category));
const hasFailures = categories.length > 0;
const summary = hasFailures
? `Recent failure signals detected across ${categories.join(", ")}.`
: "No recent failure signals detected.";
return {
hasFailures,
categories,
summary,
signals,
};
}
// ---------------------------------------------------------------------------
// Session Summary
// ---------------------------------------------------------------------------
export function summarizeBrowserSession(session: Record<string, any> = {}): SessionSummary {
const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] as ActionEntry[] };
const actionEntries: ActionEntry[] = actionTimeline.entries ?? [];
const retainedActionCount: number = session.retainedActionCount ?? actionEntries.length;
const totalActionCount: number = session.totalActionCount ?? retainedActionCount;
const pages: Array<Record<string, any>> = session.pages ?? [];
const consoleEntries: Array<Record<string, any>> = session.consoleEntries ?? [];
const networkEntries: Array<Record<string, any>> = session.networkEntries ?? [];
const dialogEntries: Array<Record<string, any>> = session.dialogEntries ?? [];
const actionStatusCounts = actionEntries.reduce(
(acc: Record<string, number>, entry: ActionEntry) => {
const status = summarizeActionStatus(entry.status);
acc[status] = (acc[status] ?? 0) + 1;
return acc;
},
{ success: 0, error: 0, running: 0 },
);
const waitEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_wait_for");
const assertEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_assert");
const consoleErrors = consoleEntries.filter((entry: Record<string, any>) => entry.type === "error" || entry.type === "pageerror");
const failedRequests = networkEntries.filter((entry: Record<string, any>) => entry.failed || (typeof entry.status === "number" && entry.status >= 400));
const activePage = pages.find((page: Record<string, any>) => page.isActive) ?? pages[0] ?? null;
const caveats: string[] = [];
if (totalActionCount > retainedActionCount) {
caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`);
}
if (
actionEntries.some((entry) => looksBoundedWarning(entry.warningSummary) || looksBoundedWarning(entry.error)) ||
consoleEntries.some((entry) => looksBoundedWarning(entry.text) || looksBoundedWarning(entry.message)) ||
consoleEntries.length > 0
) {
caveats.push("bounded console history may hide older console events.");
}
if (failedRequests.length > 0 || networkEntries.length > 0) {
caveats.push("bounded network history may hide older requests.");
}
const failureHypothesis = buildFailureHypothesis(session);
if (!actionEntries.length && pages.length === 0 && consoleEntries.length === 0 && networkEntries.length === 0 && dialogEntries.length === 0) {
return {
counts: {
pages: 0,
actions: { total: 0, retained: 0, success: 0, error: 0, running: 0 },
waits: { total: 0, success: 0, error: 0, running: 0 },
assertions: { total: 0, passed: 0, failed: 0, running: 0 },
consoleErrors: 0,
failedRequests: 0,
dialogs: 0,
},
activePage: null,
caveats: [],
failureHypothesis,
summary: "No browser session activity recorded.",
};
}
return {
counts: {
pages: pages.length,
actions: {
total: totalActionCount,
retained: retainedActionCount,
success: actionStatusCounts.success,
error: actionStatusCounts.error,
running: actionStatusCounts.running,
},
waits: {
total: waitEntries.length,
success: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
error: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
running: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
},
assertions: {
total: assertEntries.length,
passed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
failed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
running: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
},
consoleErrors: consoleErrors.length,
failedRequests: failedRequests.length,
dialogs: dialogEntries.length,
},
activePage: activePage
? {
id: activePage.id ?? null,
title: activePage.title ?? "",
url: activePage.url ?? "",
}
: null,
caveats,
failureHypothesis,
summary: `Session: ${pages.length} page${pages.length === 1 ? "" : "s"}, ${totalActionCount} actions, ${waitEntries.length} wait${waitEntries.length === 1 ? "" : "s"}, ${assertEntries.length} assert${assertEntries.length === 1 ? "" : "s"}.${caveats.length ? ` ${caveats.join(" ")}` : ""}`,
};
}