/**
* Tree walker with iframe support (runs in browser context)
*/
import type { SnapshotNode, UidEntry } from '../types.js';
import { isRelevant, isVisible } from './elementCollector.js';
import {
getElementName,
getTextContent,
getAriaAttributes,
getComputedProperties,
} from './attributeCollector.js';
import { generateCssSelector, generateXPath } from './selectorGenerator.js';
/**
* Configuration
*/
const MAX_DEPTH = 10;
const MAX_NODES = 1000;
/**
* Tree walker options
*/
export interface TreeWalkerOptions {
includeAll?: boolean;
includeIframes?: boolean;
}
/**
* Tree walker result
*/
export interface TreeWalkerResult {
tree: SnapshotNode | null;
uidMap: UidEntry[];
truncated: boolean;
}
/**
* Internal walk result with bubble-up support
*/
interface WalkResult {
node: SnapshotNode | null;
relevantChildren: SnapshotNode[];
}
/**
* Walk DOM tree and collect snapshot
*/
export function walkTree(
rootElement: Element,
snapshotId: number,
options: TreeWalkerOptions = {}
): TreeWalkerResult {
const { includeAll = false, includeIframes = true } = options;
let counter = 0;
const uidMap: UidEntry[] = [];
let truncated = false;
function walk(el: Element, depth: number): WalkResult {
// Check limits
if (depth > MAX_DEPTH) {
truncated = true;
return { node: null, relevantChildren: [] };
}
if (counter >= MAX_NODES) {
truncated = true;
return { node: null, relevantChildren: [] };
}
// Check relevance (except root)
const tag = el.tagName.toLowerCase();
const isRoot = tag === 'body' || tag === 'html';
// Determine if element is relevant based on mode
let elementIsRelevant: boolean;
if (includeAll) {
// Include all mode: only check visibility
elementIsRelevant = isRoot || isVisible(el);
} else {
// Standard mode: use full relevance filter
elementIsRelevant = isRoot || isRelevant(el);
}
// Always walk children first (bubble-up pattern)
const childResults: SnapshotNode[] = [];
// Handle iframes
if (tag === 'iframe' && includeIframes && elementIsRelevant) {
try {
const iframe = el as HTMLIFrameElement;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc?.body) {
// Same-origin iframe - traverse it
const iframeResult = walk(iframeDoc.body, depth + 1);
if (iframeResult.node) {
iframeResult.node.isIframe = true;
iframeResult.node.frameSrc = iframe.src;
childResults.push(iframeResult.node);
}
}
} catch (e) {
// Cross-origin error - will be handled when creating node
}
} else {
// Walk regular children
for (let i = 0; i < el.children.length; i++) {
if (counter >= MAX_NODES) {
truncated = true;
break;
}
const child = el.children[i];
if (!child) {
continue;
}
const childResult = walk(child, depth + 1);
if (childResult.node) {
// Child is relevant, include it
childResults.push(childResult.node);
} else if (childResult.relevantChildren.length > 0) {
// Child is not relevant but has relevant descendants - bubble them up
childResults.push(...childResult.relevantChildren);
}
}
}
// Now decide if THIS element should be included
if (!elementIsRelevant) {
// Element is not relevant, but pass up its relevant children
return { node: null, relevantChildren: childResults };
}
// Element IS relevant - create node
const uid = `${snapshotId}_${counter++}`;
const css = generateCssSelector(el);
const xpath = generateXPath(el);
uidMap.push({ uid, css, xpath });
// Collect attributes
const htmlEl = el as HTMLElement;
const roleAttr = el.getAttribute('role');
const nameAttr = getElementName(el);
const textAttr = getTextContent(el);
const valueAttr = (htmlEl as any).value;
const hrefAttr = (htmlEl as any).href;
const srcAttr = (htmlEl as any).src;
const ariaAttr = getAriaAttributes(el);
const computedAttr = getComputedProperties(el);
const node: SnapshotNode = {
uid,
tag,
...(roleAttr && { role: roleAttr }),
...(nameAttr && { name: nameAttr }),
...(valueAttr && { value: valueAttr }),
...(hrefAttr && { href: hrefAttr }),
...(srcAttr && { src: srcAttr }),
...(textAttr && { text: textAttr }),
...(ariaAttr && { aria: ariaAttr }),
...(computedAttr && { computed: computedAttr }),
children: childResults,
};
// Special handling for cross-origin iframes
if (tag === 'iframe' && includeIframes) {
try {
const iframe = el as HTMLIFrameElement;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDoc?.body) {
// Cross-origin or no body - placeholder
node.isIframe = true;
node.frameSrc = iframe.src;
node.crossOrigin = true;
}
} catch (e) {
// Cross-origin error - add placeholder
node.isIframe = true;
node.frameSrc = (el as HTMLIFrameElement).src;
node.crossOrigin = true;
}
}
return { node, relevantChildren: [] };
}
const result = walk(rootElement, 0);
return {
tree: result.node,
uidMap,
truncated,
};
}