/** * Nested window / browsing-context crawler — discovers and records frames, * embedded apps, and popup windows that are already open in the browser * context at the time of a crawl step. * * Complements multi-context-handler.ts: that module handles proactive popup * interception via context.on('page'); this module snapshots what is already * present in the frame tree and context page list. */ import type { Page, BrowserContext } from 'playwright'; export type BrowsingContextType = 'page' | 'frame' | 'popup' | 'embedded-app'; export interface BrowsingContextNode { id: string; type: BrowsingContextType; url: string; parentId: string | null; depth: number; title: string; interactiveCount: number; accessible: boolean; isEmbeddedApp: boolean; } export interface NestedCrawlResult { contextTree: BrowsingContextNode[]; totalContextsFound: number; maxDepthReached: number; inaccessibleCount: number; frameUrls: string[]; embeddedAppUrls: string[]; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Deterministic 12-char hex id derived from context type + URL via FNV-1a. * No external deps — algorithm implemented inline. */ function generateContextId(type: string, url: string): string { let h = 0x811c9dc5; const s = type + url; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0').slice(0, 12); } /** * Returns the frame depth by walking the parentFrame() chain. * The main frame has depth 0. */ function computeFrameDepth(frame: import('playwright').Frame): number { let depth = 0; let current = frame.parentFrame(); while (current !== null) { depth++; current = current.parentFrame(); } return depth; } /** * Walks all frames attached to the page and returns a BrowsingContextNode * for each reachable, non-trivial frame up to maxDepth. */ async function crawlFrameTree( page: any, maxDepth = 4, ): Promise { const nodes: BrowsingContextNode[] = []; try { const frames = page.frames(); const limited = frames.slice(0, 200); for (const frame of limited) { try { const url = frame.url(); // Skip blank / synthetic frames if ( !url || url === 'about:blank' || url.startsWith('javascript:') || url.startsWith('data:') ) { continue; } const depth = computeFrameDepth(frame); if (depth > maxDepth) continue; const parentFrame = frame.parentFrame(); const parentId = parentFrame ? generateContextId('frame', parentFrame.url()) : null; let title = ''; let interactiveCount = 0; let accessible = false; try { title = await frame.title(); interactiveCount = await frame .locator('button, a[href], input, select') .count(); accessible = true; } catch { // cross-origin or sandboxed — treat as inaccessible } nodes.push({ id: generateContextId('frame', url), type: 'frame', url, parentId, depth, title, interactiveCount, accessible, isEmbeddedApp: false, }); } catch { // skip individual frame errors } } } catch { // skip entirely if frames() fails } return nodes; } /** * Evaluates the page DOM to identify iframes that look like full embedded * applications (large viewport coverage, or app-class/id/src signals). * Returns up to 5 absolute URLs. */ async function detectEmbeddedApps(page: any): Promise { try { const srcs = await (page.evaluate as any)(() => { return Array.from(document.querySelectorAll('iframe')) .filter((f) => { const r = f.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const isLarge = r.width >= vw * 0.7 && r.height >= vh * 0.7; const hasAppClass = /embed|dashboard|app-frame|portal/i.test( f.className + f.id + (f.getAttribute('src') ?? ''), ); return isLarge || hasAppClass; }) .map((f) => f.src) .filter(Boolean); }); const baseUrl = page.url(); const resolved: string[] = []; for (const src of srcs.slice(0, 5)) { try { resolved.push(new URL(src, baseUrl).href); } catch { // skip unparseable src } } return resolved; } catch { return []; } } /** * Snapshots all pages currently open in the context that are not the seed * URL and records them as popup nodes at depth 1. * * Proactive interception (context.on('page')) is handled separately by * attachNewTabListener in multi-context-handler.ts. */ async function crawlPopupChain( context: BrowserContext, seedUrl: string, ): Promise { const nodes: BrowsingContextNode[] = []; try { const pages = context.pages(); for (const pg of pages) { try { const url = pg.url(); if (!url || url === 'about:blank' || url === seedUrl) continue; let title = ''; let interactiveCount = 0; let accessible = false; try { title = await pg.title(); interactiveCount = await pg .locator('button, a[href], input, select') .count(); accessible = true; } catch { // page may be closed or cross-origin restricted } nodes.push({ id: generateContextId('popup', url), type: 'popup', url, parentId: null, // chain depth not reliably trackable for existing pages depth: 1, title, interactiveCount, accessible, isEmbeddedApp: false, }); } catch { // skip individual page errors } } } catch { // skip if context.pages() fails } return nodes; } // --------------------------------------------------------------------------- // Exported entry point // --------------------------------------------------------------------------- /** * Discovers all nested browsing contexts reachable from the current page: * sub-frames, embedded applications, and already-open popup pages. * * All failures are non-fatal — errors are caught and an empty result is * returned so the caller's crawl step can continue uninterrupted. */ export async function crawlNestedContexts( page: any, context: BrowserContext | null, seedUrl: string, opts?: { maxFrameDepth?: number; maxPopupDepth?: number }, ): Promise { const empty: NestedCrawlResult = { contextTree: [], totalContextsFound: 0, maxDepthReached: 0, inaccessibleCount: 0, frameUrls: [], embeddedAppUrls: [], }; try { const maxFrameDepth = opts?.maxFrameDepth ?? 4; // Run independent discovery passes concurrently const [frameNodes, embeddedAppUrls, popupNodes] = await Promise.all([ crawlFrameTree(page, maxFrameDepth).catch((): BrowsingContextNode[] => []), detectEmbeddedApps(page).catch((): string[] => []), context ? crawlPopupChain(context, seedUrl).catch((): BrowsingContextNode[] => []) : Promise.resolve([]), ]); // Promote frame nodes that match an embedded-app URL const embeddedSet = new Set(embeddedAppUrls); for (const node of frameNodes) { if (embeddedSet.has(node.url)) { node.isEmbeddedApp = true; node.type = 'embedded-app'; } } const contextTree: BrowsingContextNode[] = [...frameNodes, ...popupNodes]; // Deduplicate by id (same URL appearing in both lists) const seen = new Set(); const deduped = contextTree.filter((n) => { if (seen.has(n.id)) return false; seen.add(n.id); return true; }); const maxDepthReached = deduped.reduce((max, n) => Math.max(max, n.depth), 0); const inaccessibleCount = deduped.filter((n) => !n.accessible).length; const frameUrls = [...new Set(frameNodes.map((n) => n.url))]; return { contextTree: deduped, totalContextsFound: deduped.length, maxDepthReached, inaccessibleCount, frameUrls, embeddedAppUrls, }; } catch { return empty; } }