Files
moltbot/src/browser/pw-role-snapshot.ts
George Zhang eee5d7c6b0 fix(browser): harden existing-session driver validation and session lifecycle (#45682)
* fix(browser): harden existing-session driver validation, session lifecycle, and code quality

Fix config validation rejecting existing-session profiles that lack
cdpPort/cdpUrl (they use Chrome MCP auto-connect instead). Fix callTool
tearing down the MCP session on tool-level errors (element not found,
script error), which caused expensive npx re-spawns. Skip unnecessary
CDP port allocation for existing-session profiles. Remove redundant
ensureChromeMcpAvailable call in isReachable.

Extract shared ARIA role sets (INTERACTIVE_ROLES, CONTENT_ROLES,
STRUCTURAL_ROLES) into snapshot-roles.ts so both the Playwright and
Chrome MCP snapshot paths stay in sync. Add usesChromeMcp capability
flag and replace ~20 scattered driver === "existing-session" string
checks with the centralized flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browser): harden existing-session driver validation and session lifecycle (#45682) (thanks @odysseus0)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 20:21:47 -07:00

403 lines
10 KiB
TypeScript

import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type RoleRef = {
role: string;
name?: string;
/** Index used only when role+name duplicates exist. */
nth?: number;
};
export type RoleRefMap = Record<string, RoleRef>;
export type RoleSnapshotStats = {
lines: number;
chars: number;
refs: number;
interactive: number;
};
export type RoleSnapshotOptions = {
/** Only include interactive elements (buttons, links, inputs, etc.). */
interactive?: boolean;
/** Maximum depth to include (0 = root only). */
maxDepth?: number;
/** Remove unnamed structural elements and empty branches. */
compact?: boolean;
};
export function getRoleSnapshotStats(snapshot: string, refs: RoleRefMap): RoleSnapshotStats {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {
lines: snapshot.split("\n").length,
chars: snapshot.length,
refs: Object.keys(refs).length,
interactive,
};
}
function getIndentLevel(line: string): number {
const match = line.match(/^(\s*)/);
return match ? Math.floor(match[1].length / 2) : 0;
}
function matchInteractiveSnapshotLine(
line: string,
options: RoleSnapshotOptions,
): { roleRaw: string; role: string; name?: string; suffix: string } | null {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
return null;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
return null;
}
const role = roleRaw.toLowerCase();
return {
roleRaw,
role,
...(name ? { name } : {}),
suffix,
};
}
type RoleNameTracker = {
counts: Map<string, number>;
refsByKey: Map<string, string[]>;
getKey: (role: string, name?: string) => string;
getNextIndex: (role: string, name?: string) => number;
trackRef: (role: string, name: string | undefined, ref: string) => void;
getDuplicateKeys: () => Set<string>;
};
function createRoleNameTracker(): RoleNameTracker {
const counts = new Map<string, number>();
const refsByKey = new Map<string, string[]>();
return {
counts,
refsByKey,
getKey(role: string, name?: string) {
return `${role}:${name ?? ""}`;
},
getNextIndex(role: string, name?: string) {
const key = this.getKey(role, name);
const current = counts.get(key) ?? 0;
counts.set(key, current + 1);
return current;
},
trackRef(role: string, name: string | undefined, ref: string) {
const key = this.getKey(role, name);
const list = refsByKey.get(key) ?? [];
list.push(ref);
refsByKey.set(key, list);
},
getDuplicateKeys() {
const out = new Set<string>();
for (const [key, refs] of refsByKey) {
if (refs.length > 1) {
out.add(key);
}
}
return out;
},
};
}
function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker) {
const duplicates = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicates.has(key)) {
delete refs[ref]?.nth;
}
}
}
function compactTree(tree: string) {
const lines = tree.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (line.includes("[ref=")) {
result.push(line);
continue;
}
if (line.includes(":") && !line.trimEnd().endsWith(":")) {
result.push(line);
continue;
}
const currentIndent = getIndentLevel(line);
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j += 1) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent) {
break;
}
if (lines[j]?.includes("[ref=")) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join("\n");
}
function processLine(
line: string,
refs: RoleRefMap,
options: RoleSnapshotOptions,
tracker: RoleNameTracker,
nextRef: () => string,
): string | null {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
return options.interactive ? null : line;
}
const [, prefix, roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
return options.interactive ? null : line;
}
const role = roleRaw.toLowerCase();
const isInteractive = INTERACTIVE_ROLES.has(role);
const isContent = CONTENT_ROLES.has(role);
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.interactive && !isInteractive) {
return null;
}
if (options.compact && isStructural && !name) {
return null;
}
const shouldHaveRef = isInteractive || (isContent && name);
if (!shouldHaveRef) {
return line;
}
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
tracker.trackRef(role, name, ref);
refs[ref] = {
role,
name,
nth,
};
let enhanced = `${prefix}${roleRaw}`;
if (name) {
enhanced += ` "${name}"`;
}
enhanced += ` [ref=${ref}]`;
if (nth > 0) {
enhanced += ` [nth=${nth}]`;
}
if (suffix) {
enhanced += suffix;
}
return enhanced;
}
type InteractiveSnapshotLine = NonNullable<ReturnType<typeof matchInteractiveSnapshotLine>>;
function buildInteractiveSnapshotLines(params: {
lines: string[];
options: RoleSnapshotOptions;
resolveRef: (parsed: InteractiveSnapshotLine) => { ref: string; nth?: number } | null;
recordRef: (parsed: InteractiveSnapshotLine, ref: string, nth?: number) => void;
includeSuffix: (suffix: string) => boolean;
}): string[] {
const out: string[] = [];
for (const line of params.lines) {
const parsed = matchInteractiveSnapshotLine(line, params.options);
if (!parsed) {
continue;
}
if (!INTERACTIVE_ROLES.has(parsed.role)) {
continue;
}
const resolved = params.resolveRef(parsed);
if (!resolved?.ref) {
continue;
}
params.recordRef(parsed, resolved.ref, resolved.nth);
let enhanced = `- ${parsed.roleRaw}`;
if (parsed.name) {
enhanced += ` "${parsed.name}"`;
}
enhanced += ` [ref=${resolved.ref}]`;
if ((resolved.nth ?? 0) > 0) {
enhanced += ` [nth=${resolved.nth}]`;
}
if (params.includeSuffix(parsed.suffix)) {
enhanced += parsed.suffix;
}
out.push(enhanced);
}
return out;
}
export function parseRoleRef(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith("@")
? trimmed.slice(1)
: trimmed.startsWith("ref=")
? trimmed.slice(4)
: trimmed;
return /^e\d+$/.test(normalized) ? normalized : null;
}
export function buildRoleSnapshotFromAriaSnapshot(
ariaSnapshot: string,
options: RoleSnapshotOptions = {},
): { snapshot: string; refs: RoleRefMap } {
const lines = ariaSnapshot.split("\n");
const refs: RoleRefMap = {};
const tracker = createRoleNameTracker();
let counter = 0;
const nextRef = () => {
counter += 1;
return `e${counter}`;
};
if (options.interactive) {
const result = buildInteractiveSnapshotLines({
lines,
options,
resolveRef: ({ role, name }) => {
const ref = nextRef();
const nth = tracker.getNextIndex(role, name);
tracker.trackRef(role, name, ref);
return { ref, nth };
},
recordRef: ({ role, name }, ref, nth) => {
refs[ref] = {
role,
name,
nth,
};
},
includeSuffix: (suffix) => suffix.includes("["),
});
removeNthFromNonDuplicates(refs, tracker);
return {
snapshot: result.join("\n") || "(no interactive elements)",
refs,
};
}
const result: string[] = [];
for (const line of lines) {
const processed = processLine(line, refs, options, tracker, nextRef);
if (processed !== null) {
result.push(processed);
}
}
removeNthFromNonDuplicates(refs, tracker);
const tree = result.join("\n") || "(empty)";
return {
snapshot: options.compact ? compactTree(tree) : tree,
refs,
};
}
function parseAiSnapshotRef(suffix: string): string | null {
const match = suffix.match(/\[ref=(e\d+)\]/i);
return match ? match[1] : null;
}
/**
* Build a role snapshot from Playwright's AI snapshot output while preserving Playwright's own
* aria-ref ids (e.g. ref=e13). This makes the refs self-resolving across calls.
*/
export function buildRoleSnapshotFromAiSnapshot(
aiSnapshot: string,
options: RoleSnapshotOptions = {},
): { snapshot: string; refs: RoleRefMap } {
const lines = String(aiSnapshot ?? "").split("\n");
const refs: RoleRefMap = {};
if (options.interactive) {
const out = buildInteractiveSnapshotLines({
lines,
options,
resolveRef: ({ suffix }) => {
const ref = parseAiSnapshotRef(suffix);
return ref ? { ref } : null;
},
recordRef: ({ role, name }, ref) => {
refs[ref] = { role, ...(name ? { name } : {}) };
},
includeSuffix: () => true,
});
return {
snapshot: out.join("\n") || "(no interactive elements)",
refs,
};
}
const out: string[] = [];
for (const line of lines) {
const depth = getIndentLevel(line);
if (options.maxDepth !== undefined && depth > options.maxDepth) {
continue;
}
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
out.push(line);
continue;
}
const [, , roleRaw, name, suffix] = match;
if (roleRaw.startsWith("/")) {
out.push(line);
continue;
}
const role = roleRaw.toLowerCase();
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.compact && isStructural && !name) {
continue;
}
const ref = parseAiSnapshotRef(suffix);
if (ref) {
refs[ref] = { role, ...(name ? { name } : {}) };
}
out.push(line);
}
const tree = out.join("\n") || "(empty)";
return {
snapshot: options.compact ? compactTree(tree) : tree,
refs,
};
}