Files
Imhotep/packages/imhotep-cdp/src/session.ts
T
John Dvorak 1bc92e1f7d fix: CDP extraction concurrency + topology ID remapping + predicate completeness
CDP protocol fixes:
- session.ts: getDocumentRootNodeId() now pushes {depth:-1} full DOM tree
  (was shallow default, causing querySelectorAll to find zero nodes)
- extraction.ts: serialize resolveSelector calls (was concurrent Promise.all,
  CDP DOM.querySelectorAll is not safe for concurrent calls on same session)

Topology ID mismatch fixes (critical — backendNodeId vs solver subject ID):
- extraction.ts: add remapTopologyIds() — builds Map<backendNodeId,solverId>
  from world.subjects.domNodeId/ids, remaps all 6 topology arrays +
  clipping.subjectId + scroll.containerId
- topology.ts: createsStackingContext() now checks contain:paint|layout
  (both create stacking contexts per CSS spec, was missing)
- topology.ts: evaluateInStackingContext() handles unary (no reference)

Predicate completeness:
- predicates.ts: 3 new evaluators — intersects, touches, hasGap (indices 31-33)
- predicates.ts: escapeClippingChainOf replaced indeterminate stub with
  deterministic 2-ary evaluator (overflow + clip chain + ancestry checks)
- predicates.ts: attachedToScrollContainer arity 1→2, checks reference match
- predicates.ts: 7 aliases + 4 alignment aliases promoted to BUILTIN_PREDICATES

DSL grammar fixes:
- grammar.ts: isKeywordThatCanBePredicate() extended with all missing keywords
- lower-to-canonical.ts: add TopologyAssertion lowering (was silently dropped)
- compiler.ts: add intersects/touches/hasGap relation codes
- validator.ts: add option sets for new predicates
- lexer.ts: add intersects/touches/hasGap token kinds

Safety + infrastructure:
- joins.ts: MAX_CARTESIAN_TUPLES=100,000 guard with descriptive error
- extraction.ts: computeRequiredFacts() registers defaults + broadens topology gate
- package.json: add missing imhotep-state dependency to imhotep-playwright

Tests:
- 9 new predicate tests (intersects, touches, hasGap, escapeClippingChainOf)
- Convert stale separatedFrom/contains GAP tests to working assertions
- Add computeRequiredFacts topology gating tests (public.test.ts)
2026-05-22 10:20:06 -07:00

150 lines
4.2 KiB
TypeScript

/**
* CDP Session Management
*
* Wraps Playwright's CDP session access to provide a typed, injectable
* interface for sending Chrome DevTools Protocol commands.
*
* Design: dependency injection over optional imports. The extractor receives
* a session factory rather than importing Playwright directly.
*/
/**
* Minimal CDP session interface. Any object that can send CDP commands
* and return JSON-like responses satisfies this contract.
*/
export interface CDPSession {
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>
on(event: string, handler: (payload: unknown) => void): void
off(event: string, handler: (payload: unknown) => void): void
detach(): Promise<void>
}
/**
* Factory that creates a CDP session for a given Playwright page.
* This is injected so the package does not hard-depend on Playwright.
*/
export type CDPSessionFactory = (pageRef: unknown) => Promise<CDPSession>
/**
* Playwright-backed session factory implementation.
*
* Usage:
* const session = await createPlaywrightCDPSession(page)
*
* Where `page` is a Playwright Page object.
*/
export async function createPlaywrightCDPSession(
page: unknown
): Promise<CDPSession> {
// Playwright pages expose context() and a CDP session method.
// We access it dynamically to avoid a hard import dependency.
const pwPage = page as {
context: () => {
newCDPSession: (p: unknown) => Promise<CDPSession>
}
}
const context = pwPage.context()
const session = await context.newCDPSession(page)
return session
}
/**
* Session manager that owns the lifecycle of a CDP session.
*
* Handles attach, detach, and basic error recovery.
* Caches document root nodeId and enabled domains to minimize round-trips.
*/
export class CDPSessionManager {
private session: CDPSession | null = null
private attachPromise: Promise<CDPSession> | null = null
private readonly factory: CDPSessionFactory
private readonly pageRef: unknown
private documentRootNodeId: number | null = null
private enabledDomains = new Set<string>()
constructor(factory: CDPSessionFactory, pageRef: unknown) {
this.factory = factory
this.pageRef = pageRef
}
/**
* Ensure a session is attached. Idempotent and concurrency-safe.
*/
async attach(): Promise<CDPSession> {
if (this.session) return this.session
if (this.attachPromise) return this.attachPromise
this.attachPromise = this.factory(this.pageRef).then((s) => {
this.session = s
this.documentRootNodeId = null
this.enabledDomains.clear()
return s
})
return this.attachPromise
}
/**
* Get cached document root nodeId, or fetch and cache it.
*/
async getDocumentRootNodeId(): Promise<number> {
if (this.documentRootNodeId !== null) {
return this.documentRootNodeId
}
const s = await this.attach()
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 })
this.documentRootNodeId = result.root.nodeId
return this.documentRootNodeId
}
/**
* Enable a CDP domain if not already enabled.
*/
async enableDomain(domain: string): Promise<void> {
if (this.enabledDomains.has(domain)) {
return
}
const s = await this.attach()
await s.send(`${domain}.enable`)
this.enabledDomains.add(domain)
}
/**
* Get the current session, throwing if not attached.
*/
getSession(): CDPSession {
if (!this.session) {
throw new Error('CDP session not attached. Call attach() first.')
}
return this.session
}
/**
* Send a CDP command, automatically attaching if needed.
*/
async send<T = unknown>(
method: string,
params?: Record<string, unknown>
): Promise<T> {
const s = await this.attach()
return s.send<T>(method, params)
}
/**
* Detach and clean up the session.
*/
async detach(): Promise<void> {
if (this.session) {
await this.session.detach()
this.session = null
}
this.attachPromise = null
}
}
/**
* Convenient builder for a manager using the Playwright factory.
*/
export function createSessionManager(page: unknown): CDPSessionManager {
return new CDPSessionManager(createPlaywrightCDPSession, page)
}