diff --git a/go/examples/demo/main.go b/go/examples/demo/main.go index 88f9409c..ab0606f6 100644 --- a/go/examples/demo/main.go +++ b/go/examples/demo/main.go @@ -110,6 +110,14 @@ func mustMap(value any, label string) map[string]any { return result } +func mustString(value any, label string) string { + result, ok := value.(string) + if !ok || result == "" { + log.Fatalf("%s returned non-string value: %v", label, value) + } + return result +} + func int64Value(value any) (int64, bool) { switch typed := value.(type) { case int64: @@ -292,6 +300,47 @@ func main() { fmt.Println("Mod.evaluate ->", string(b)) } + topologyChecked := false + if mode != "direct" { + topologyRaw, err := cdp.Mod.GetTopology(nil) + if err != nil { + log.Fatalf("Mod.getTopology: %v", err) + } + topology := mustMap(topologyRaw, "Mod.getTopology") + rootFrameID := mustString(topology["rootFrameId"], "Mod.getTopology.rootFrameId") + frames := mustMap(topology["frames"], "Mod.getTopology.frames") + roots := mustMap(topology["roots"], "Mod.getTopology.roots") + contexts := mustMap(topology["contexts"], "Mod.getTopology.contexts") + if _, ok := frames[rootFrameID]; !ok { + log.Fatalf("Mod.getTopology frames missing root frame %s: %v", rootFrameID, frames) + } + hasDocumentRoot := false + for _, root := range roots { + rootMap, ok := root.(map[string]any) + if ok && rootMap["kind"] == "document" { + hasDocumentRoot = true + } + } + hasPiercerContext := false + for _, context := range contexts { + contextMap, ok := context.(map[string]any) + if ok && contextMap["world"] == "piercer" { + hasPiercerContext = true + } + } + if !hasDocumentRoot || !hasPiercerContext { + log.Fatalf("unexpected Mod.getTopology result: %v", topology) + } + topologyChecked = true + b, _ := json.Marshal(map[string]any{ + "rootFrameId": rootFrameID, + "frames": len(frames), + "roots": len(roots), + "contexts": len(contexts), + }) + fmt.Println("Mod.getTopology ->", string(b)) + } + responseMiddlewareRegistrationRaw, err := cdp.Mod.AddMiddleware(modcdp.CustomMiddleware{ Name: "Custom.echo", Phase: "response", @@ -376,7 +425,11 @@ func main() { runtimeJSON, _ := json.Marshal(runtimeEval) fmt.Println("Runtime.evaluate ->", string(runtimeJSON)) - fmt.Printf("\nSUCCESS (%s/%s): native command, custom commands, custom event, and middleware all passed\n", mode, upstreamMode) + topologyLabel := "" + if topologyChecked { + topologyLabel = "topology, " + } + fmt.Printf("\nSUCCESS (%s/%s): native command, %scustom commands, custom event, and middleware all passed\n", mode, upstreamMode, topologyLabel) // TTY-only REPL. Lets you poke at the live browser interactively; // subscribed events print as they arrive. Skip when stdin is not a tty diff --git a/go/modcdp/client/ModCDPClient.go b/go/modcdp/client/ModCDPClient.go index f48ecdd8..44895c16 100644 --- a/go/modcdp/client/ModCDPClient.go +++ b/go/modcdp/client/ModCDPClient.go @@ -1245,6 +1245,10 @@ func (d ModDomain) Ping(params map[string]any) (any, error) { return d.client.Send("Mod.ping", params) } +func (d ModDomain) GetTopology(params map[string]any) (any, error) { + return d.client.Send("Mod.getTopology", params) +} + func (c *ModCDPClient) sendCommand(method string, params map[string]any, cdpSessionID string, validateSchema bool) (any, error) { startedAt := time.Now().UnixMilli() if params == nil { diff --git a/go/modcdp/client/ModCDPClientRoutedDefaultOverrides_test.go b/go/modcdp/client/ModCDPClientRoutedDefaultOverrides_test.go index 84f5cab3..2b7e0575 100644 --- a/go/modcdp/client/ModCDPClientRoutedDefaultOverrides_test.go +++ b/go/modcdp/client/ModCDPClientRoutedDefaultOverrides_test.go @@ -179,6 +179,52 @@ func TestModCDPClientRoutedDefaultOverrides(t *testing.T) { t.Fatal("expected at least one page target to be matched to a chrome.tabs tab id") } + topologyRaw, err := cdp.Mod.GetTopology(nil) + if err != nil { + t.Fatal(err) + } + topology, ok := topologyRaw.(map[string]any) + if !ok { + t.Fatalf("Mod.getTopology returned %T: %#v", topologyRaw, topologyRaw) + } + rootFrameID, ok := topology["rootFrameId"].(string) + if !ok || rootFrameID == "" { + t.Fatalf("Mod.getTopology rootFrameId = %#v", topology["rootFrameId"]) + } + frames, ok := topology["frames"].(map[string]any) + if !ok { + t.Fatalf("Mod.getTopology frames = %T: %#v", topology["frames"], topology["frames"]) + } + if _, ok := frames[rootFrameID]; !ok { + t.Fatalf("Mod.getTopology frames missing rootFrameId %q: %#v", rootFrameID, frames) + } + roots, ok := topology["roots"].(map[string]any) + if !ok { + t.Fatalf("Mod.getTopology roots = %T: %#v", topology["roots"], topology["roots"]) + } + hasDocumentRoot := false + for _, root := range roots { + if rootMap, ok := root.(map[string]any); ok && rootMap["kind"] == "document" { + hasDocumentRoot = true + } + } + if !hasDocumentRoot { + t.Fatalf("Mod.getTopology should include at least one document root: %#v", roots) + } + contexts, ok := topology["contexts"].(map[string]any) + if !ok { + t.Fatalf("Mod.getTopology contexts = %T: %#v", topology["contexts"], topology["contexts"]) + } + hasPiercerContext := false + for _, context := range contexts { + if contextMap, ok := context.(map[string]any); ok && contextMap["world"] == "piercer" { + hasPiercerContext = true + } + } + if !hasPiercerContext { + t.Fatalf("Mod.getTopology should include a piercer execution context: %#v", contexts) + } + if _, err := cdp.Mod.AddCustomEvent(CustomEvent{Name: "Target.targetCreated"}); err != nil { t.Fatal(err) } diff --git a/go/modcdp/injector/extension.zip b/go/modcdp/injector/extension.zip index 28d163a4..46f9d304 100644 Binary files a/go/modcdp/injector/extension.zip and b/go/modcdp/injector/extension.zip differ diff --git a/js/examples/demo.ts b/js/examples/demo.ts index a0e54e8b..50da47d8 100644 --- a/js/examples/demo.ts +++ b/js/examples/demo.ts @@ -293,6 +293,26 @@ async function main() { throw new Error(`unexpected Mod.evaluate result ${JSON.stringify(modcdpEval)}`); console.log("Mod.evaluate ->", modcdpEval); + let topologyChecked = false; + if (mode !== "direct") { + const topology = assertObject(await cdp.Mod.getTopology(), "Mod.getTopology"); + if ( + typeof topology.rootFrameId !== "string" || + !topology.frames?.[topology.rootFrameId] || + !Object.values(topology.roots || {}).some((root: any) => root?.kind === "document") || + !Object.values(topology.contexts || {}).some((context: any) => context?.world === "piercer") + ) { + throw new Error(`unexpected Mod.getTopology result ${JSON.stringify(topology)}`); + } + topologyChecked = true; + console.log("Mod.getTopology ->", { + rootFrameId: topology.rootFrameId, + frames: Object.keys(topology.frames || {}).length, + roots: Object.keys(topology.roots || {}).length, + contexts: Object.keys(topology.contexts || {}).length, + }); + } + const responseMiddlewareRegistration = assertObject( await cdp.Mod.addMiddleware({ name: "Custom.echo", @@ -373,7 +393,7 @@ async function main() { console.log("Runtime.evaluate ->", runtimeEval); console.log( - `\nSUCCESS (${mode}/${upstream_mode}): native command, custom commands, custom event, and middleware all passed`, + `\nSUCCESS (${mode}/${upstream_mode}): native command, ${topologyChecked ? "topology, " : ""}custom commands, custom event, and middleware all passed`, ); // Drop into an interactive prompt when stdin is a TTY. Lets you poke at diff --git a/js/src/client/ModCDPClient.ts b/js/src/client/ModCDPClient.ts index c16619c6..609f6314 100644 --- a/js/src/client/ModCDPClient.ts +++ b/js/src/client/ModCDPClient.ts @@ -750,6 +750,8 @@ export class ModCDPClient extends ModCDPEventEmitter { } this.command_params_schemas.set("Mod.evaluate", Mod.EvaluateParams); this.command_result_schemas.set("Mod.evaluate", Mod.EvaluateResponse); + this.command_params_schemas.set("Mod.getTopology", Mod.GetTopologyParams); + this.command_result_schemas.set("Mod.getTopology", Mod.GetTopologyResponse); this.command_params_schemas.set("Mod.addCustomCommand", Mod.AddCustomCommandParams); this.command_result_schemas.set("Mod.addCustomCommand", Mod.AddCustomCommandResponse); this.command_params_schemas.set("Mod.addCustomEvent", Mod.AddCustomEventParams); diff --git a/js/src/router/AutoSessionRouter.ts b/js/src/router/AutoSessionRouter.ts index 4e9fd573..91b461c5 100644 --- a/js/src/router/AutoSessionRouter.ts +++ b/js/src/router/AutoSessionRouter.ts @@ -1,52 +1,71 @@ import type { cdp } from "../types/generated/cdp.js"; import { commands as nativeCommandSchemas } from "../types/generated/zod.js"; import type { CdpCommandSchema } from "../types/generated/zod/helpers.js"; +import * as DOM from "../types/generated/zod/DOM.js"; +import * as Page from "../types/generated/zod/Page.js"; import * as Runtime from "../types/generated/zod/Runtime.js"; import * as Target from "../types/generated/zod/Target.js"; import type { ServerUpstreamTransport, TargetRoute } from "../server/ServerUpstreamTransport.js"; import { CdpDebuggeeCommandParamsSchema, type CdpDebuggeeCommandParams, + type ModCDPGetTopologyParams, + type ModCDPTopology, + type ModCDPTopologyDomRoot, + type ModCDPTopologyExecutionContext, + type ModCDPTopologyFrame, + type ModCDPTopologyTarget, type ProtocolParams, type ProtocolResult, } from "../types/modcdp.js"; +type FrameTree = cdp.types.ts.Page.FrameTree; +type DomNode = cdp.types.ts.DOM.Node; type TargetInfo = cdp.types.ts.Target.TargetInfo; -type RecordedTarget = Partial & { - targetId: cdp.types.ts.Target.TargetID; - type: string; - sessionId?: cdp.types.ts.Target.SessionID | null; +type ContextSelector = { + world: string; + worldName?: string; }; type ExecutionContextWaiter = { - resolve: (context_id: cdp.types.ts.Runtime.ExecutionContextId) => void; + resolve: (context: ModCDPTopologyExecutionContext) => void; reject: (error: Error) => void; timeout: ReturnType; + matches: (context: ModCDPTopologyExecutionContext) => boolean; }; +const topologyConcurrency = 8; +const piercerWorldName = "__modcdp_piercer__"; const native_commands_by_id: ReadonlyMap = new Map( Object.values(nativeCommandSchemas).map((command) => [command.id, command]), ); +const targetAutoAttachParams = { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, +} satisfies cdp.types.ts.Target.SetAutoAttachParams; + /** * Owns ModCDP's browser graph and target/session/context routing policy. * * AutoSessionRouter records Target/Page/Runtime events, maintains the current - * target-to-session graph, and hydrates target routes on demand. It does not - * know how commands are physically delivered. Loopback WebSocket request ids, - * chrome.debugger debuggee selection, native event source normalization, and - * upstream setup all live behind the ServerUpstreamTransport interface. + * target-to-session graph, hydrates target routes on demand, creates execution + * contexts, and builds Mod.getTopology output. It does not know how commands + * are physically delivered. Loopback WebSocket request ids, chrome.debugger + * debuggee selection, native event source normalization, and upstream setup all + * live behind the ServerUpstreamTransport interface. * * State machine: * 1. Target records arrive from Target.getTargets or target-info events. * 2. ensureRouteForTarget attaches a target and records either a native session id * or a sessionless attached target supplied by the upstream. - * 3. Runtime events add or invalidate execution context records. - * 4. Target detach and target destroy events remove only the state affected by - * the browser event. + * 3. Runtime/Page events add or invalidate execution context records. + * 4. Frame detach, navigation, target detach, and target destroy events remove + * only the state affected by the browser event. */ export class AutoSessionRouter { // TargetID -> native flattened Target.SessionID. Updated by ensureRouteForTarget - // and Target.attachedToTarget events; read by routing and injectors. + // and Target.attachedToTarget events; read by routing, injectors, and topology. readonly sessionId_from_targetId = new Map(); // Native flattened Target.SessionID -> TargetID. Updated with @@ -54,15 +73,21 @@ export class AutoSessionRouter { readonly targetId_from_sessionId = new Map(); // TargetID -> latest target metadata plus router-owned session metadata. - // Updated from target discovery/events; read by target selection. - readonly targets = new Map(); + // Updated from target discovery/events; read by topology and target selection. + readonly targets = new Map(); + + // Context key -> execution context. The key is Chrome's uniqueId when present, + // otherwise target/session plus context id. Updated by Runtime events and by + // Page.createIsolatedWorld; read by waits, DOM root resolution, and topology. + readonly contexts = new Map(); // SessionID -> first Runtime execution context id observed for that session. // Updated by Runtime.executionContextCreated; read by ModCDPClient injectors. readonly execution_contexts = new Map(); - // Context waiters keyed by native session id. Added by waitForExecutionContext - // and resolved/rejected by recordExecutionContext and invalidation methods. + // Context waiters keyed by native session id or by target id for sessionless + // upstreams. Added by waitForExecutionContextMatching and resolved/rejected by + // recordExecutionContext and invalidation methods. private readonly execution_context_waiters = new Map>(); // Semantic upstream selected by the owner. The router calls methods on this @@ -150,31 +175,174 @@ export class AutoSessionRouter { this.upstream.on(Runtime.ExecutionContextsClearedEvent, (_event, _targetId, sessionId) => { if (sessionId) this.forgetExecutionContextsForRoute(sessionId); }), + this.upstream.on(Page.FrameNavigatedEvent, (event, targetId, sessionId) => { + this.forgetExecutionContextsForFrame(sessionId, targetId, event.frame.id); + }), + this.upstream.on(Page.FrameDetachedEvent, (event, targetId, sessionId) => { + this.forgetExecutionContextsForFrame(sessionId, targetId, event.frameId); + }), ]; return { remove: () => subscriptions.forEach((subscription) => subscription.remove()) }; } /** Wait for the first execution context associated with a real session id. */ waitForExecutionContext(sessionId: string | null, { timeout_ms }: { timeout_ms?: number } = {}): Promise { - const effective_timeout_ms = timeout_ms ?? this.loopback_execution_context_timeout_ms; - if (!sessionId) return Promise.reject(new Error("Cannot wait for a Runtime execution context without a session.")); - const existing = this.execution_contexts.get(sessionId); - if (existing != null) return Promise.resolve(existing); - return new Promise((resolve, reject) => { - const waiter: ExecutionContextWaiter = { - resolve, - reject, - timeout: setTimeout(() => { - const waiters = this.execution_context_waiters.get(sessionId); - waiters?.delete(waiter); - if (waiters?.size === 0) this.execution_context_waiters.delete(sessionId); - reject(new Error(`Timed out waiting for Runtime.executionContextCreated for session ${sessionId}.`)); - }, effective_timeout_ms), + return this.waitForExecutionContextMatching( + (context) => context.sessionId === sessionId, + sessionId, + timeout_ms, + ).then((context) => context.id); + } + + /** Ensure the requested execution context exists for a frame. */ + async ensureExecutionContext( + frame: { frameId: cdp.types.ts.Page.FrameId; targetId: cdp.types.ts.Target.TargetID }, + selector: ContextSelector = { world: "main" }, + ): Promise { + const route = await this.ensureRouteForTarget(frame.targetId); + const existing = this.findExecutionContext(route.targetId, route.sessionId, frame.frameId, selector); + if (existing) return existing; + + await this.upstream.send(Runtime.EnableCommand, {}, route); + if (selector.world === "isolated" || selector.world === "piercer") { + const created = await this.upstream.send( + Page.CreateIsolatedWorldCommand, + { + frameId: frame.frameId, + worldName: selector.worldName ?? (selector.world === "piercer" ? piercerWorldName : undefined), + grantUniveralAccess: true, + }, + route, + ); + const createdContext = this.findExecutionContext(route.targetId, route.sessionId, frame.frameId, selector); + if (createdContext?.id === created.executionContextId) return createdContext; + const context: ModCDPTopologyExecutionContext = { + id: created.executionContextId, + sessionId: route.sessionId, + targetId: route.targetId, + frameId: frame.frameId, + world: selector.world === "piercer" ? "piercer" : selector.worldName || "isolated", + name: selector.worldName, }; - const waiters = this.execution_context_waiters.get(sessionId); - if (waiters) waiters.add(waiter); - else this.execution_context_waiters.set(sessionId, new Set([waiter])); + this.contexts.set(this.contextKey(route.targetId, route.sessionId, context.id, context.uniqueId), context); + return context; + } + + return await this.waitForExecutionContextMatching( + (context) => + context.targetId === route.targetId && + context.sessionId === route.sessionId && + context.frameId === frame.frameId && + context.world === selector.world, + route.sessionId ?? route.targetId, + ); + } + + /** Build the current target/frame/DOM-root/execution-context topology. */ + async getTopology(params: ModCDPGetTopologyParams = {}): Promise { + const objectGroup = `modcdp-topology-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const targetInfos = await this.upstream.getTargets(); + for (const targetInfo of targetInfos) this.recordTarget(targetInfo); + + const rootTarget = this.resolveRootTarget(params, targetInfos); + if (rootTarget == null) throw new Error("Mod.getTopology could not resolve a page target."); + const frames = new Map(); + const rootRoute = await this.enableTarget(rootTarget.targetId); + const rootTree = (await this.upstream.send(Page.GetFrameTreeCommand, {}, rootRoute)).frameTree; + const rootFrameId = rootTree.frame.id; + this.recordFrameTree(rootTree, rootTarget.targetId, null, frames); + + const oopifTargets = targetInfos.filter( + (target) => target.type === "iframe" && target.parentFrameId && !frames.has(target.targetId), + ); + await runTopologyQueue(oopifTargets, async (target) => { + const route = await this.enableTarget(target.targetId); + const frameTree = (await this.upstream.send(Page.GetFrameTreeCommand, {}, route)).frameTree; + this.recordFrameTree(frameTree, target.targetId, target.parentFrameId ?? null, frames); + }); + + await runTopologyQueue([...frames.entries()], async ([frameId, frame]) => { + if (!frame.parentFrameId) return; + const parent = frames.get(frame.parentFrameId); + if (!parent) return; + const parentRoute = await this.ensureRouteForTarget(parent.targetId); + const owner = await this.upstream.send(DOM.GetFrameOwnerCommand, { frameId }, parentRoute); + if (owner.backendNodeId != null) frame.outerBackendNodeId = owner.backendNodeId; + }); + + const contexts = new Map(); + const roots = new Map(); + await runTopologyQueue([...frames.entries()], async ([frameId, frame]) => { + const context = await this.ensureExecutionContext({ frameId, targetId: frame.targetId }, { world: "piercer" }); + contexts.set(this.contextKey(context.targetId, context.sessionId ?? null, context.id, context.uniqueId), context); + const rootObject = await this.upstream.send( + Runtime.EvaluateCommand, + { + expression: "document.documentElement", + objectGroup, + ...(context.uniqueId ? { uniqueContextId: context.uniqueId } : { contextId: context.id }), + }, + context, + ); + const objectId = rootObject.result.objectId; + if (!objectId) throw new Error(`Mod.getTopology could not resolve document root for frameId=${frameId}.`); + const node = ( + await this.upstream.send( + DOM.DescribeNodeCommand, + { + objectId, + }, + context, + ) + ).node; + roots.set(objectId, { + kind: "document", + frameId, + outerBackendNodeId: frame.outerBackendNodeId ?? null, + innerBackendNodeId: node.backendNodeId ?? null, + executionContextId: context.id, + ...(context.uniqueId ? { uniqueContextId: context.uniqueId } : {}), + }); + }); + + await runTopologyQueue([...new Set([...frames.values()].map((frame) => frame.targetId))], async (targetId) => { + const route = await this.ensureRouteForTarget(targetId); + const document = await this.upstream.send( + DOM.GetDocumentCommand, + { + depth: -1, + pierce: true, + }, + route, + ); + await this.recordShadowRoots(document.root, frames, roots, objectGroup); }); + + for (const context of this.contexts.values()) { + if ([...frames.values()].some((frame) => frame.targetId === context.targetId)) { + contexts.set( + this.contextKey(context.targetId, context.sessionId ?? null, context.id, context.uniqueId), + context, + ); + } + } + + return { + objectGroup, + rootFrameId, + frames: Object.fromEntries(frames), + roots: Object.fromEntries(roots), + targets: Object.fromEntries( + [...this.targets].filter(([targetId]) => targetInfos.some((target) => target.targetId === targetId)), + ), + contexts: Object.fromEntries(contexts), + }; + } + + private resolveRootTarget(params: ModCDPGetTopologyParams, targetInfos: TargetInfo[]): TargetInfo | null { + const requestedTargetId = params.rootTargetId ?? params.targetId ?? null; + if (requestedTargetId) return targetInfos.find((target) => target.targetId === requestedTargetId) ?? null; + return targetInfos.find((target) => target.type === "page" && !target.url.startsWith("devtools://")) ?? null; } private async resolveTargetId(params: CdpDebuggeeCommandParams): Promise { @@ -187,10 +355,93 @@ export class AutoSessionRouter { ); } + private async enableTarget(targetId: cdp.types.ts.Target.TargetID): Promise { + const route = await this.ensureRouteForTarget(targetId); + await Promise.all([ + this.upstream.send(Page.EnableCommand, {}, route), + this.upstream.send(DOM.EnableCommand, {}, route), + this.upstream.send(Runtime.EnableCommand, {}, route), + this.upstream.send(Target.SetAutoAttachCommand, targetAutoAttachParams, route).catch(() => ({})), + ]); + return route; + } + + private recordFrameTree( + tree: FrameTree, + targetId: cdp.types.ts.Target.TargetID, + parentFrameId: cdp.types.ts.Page.FrameId | null, + frames: Map, + ): void { + const frameId = tree.frame.id; + frames.set(frameId, { + targetId, + url: tree.frame.url ?? null, + parentFrameId: tree.frame.parentId ?? parentFrameId ?? null, + }); + for (const child of tree.childFrames ?? []) this.recordFrameTree(child, targetId, frameId, frames); + } + + private async recordShadowRoots( + node: DomNode, + frames: Map, + roots: Map, + objectGroup: string, + frameId: cdp.types.ts.Page.FrameId | null = null, + hostBackendNodeId: cdp.types.ts.DOM.BackendNodeId | null = null, + ): Promise { + const currentFrameId = node.frameId ?? frameId; + for (const shadowRoot of node.shadowRoots ?? []) { + if (currentFrameId) { + const frame = frames.get(currentFrameId); + const context = frame + ? this.findExecutionContext(frame.targetId, null, currentFrameId, { world: "piercer" }) + : null; + if (frame && context) { + const objectId = ( + await this.upstream.send( + DOM.ResolveNodeCommand, + { + backendNodeId: shadowRoot.backendNodeId, + executionContextId: context.id, + objectGroup, + }, + context, + ) + ).object.objectId; + if (objectId) { + roots.set(objectId, { + kind: "shadow", + frameId: currentFrameId, + outerBackendNodeId: hostBackendNodeId ?? node.backendNodeId ?? null, + innerBackendNodeId: shadowRoot.backendNodeId ?? null, + mode: shadowRoot.shadowRootType, + executionContextId: context.id, + ...(context.uniqueId ? { uniqueContextId: context.uniqueId } : {}), + }); + } + } + } + await this.recordShadowRoots(shadowRoot, frames, roots, objectGroup, currentFrameId, node.backendNodeId ?? null); + } + for (const child of node.children ?? []) { + await this.recordShadowRoots(child, frames, roots, objectGroup, currentFrameId, hostBackendNodeId); + } + if (node.contentDocument) { + await this.recordShadowRoots( + node.contentDocument, + frames, + roots, + objectGroup, + node.contentDocument.frameId ?? currentFrameId, + hostBackendNodeId, + ); + } + } + private recordTarget(targetInfo: TargetInfo): void { const sessionId = this.sessionId_from_targetId.get(targetInfo.targetId); const existing = this.targets.get(targetInfo.targetId); - const target: RecordedTarget = { + const target: ModCDPTopologyTarget = { ...targetInfo, targetId: targetInfo.targetId, type: targetInfo.type, @@ -203,7 +454,7 @@ export class AutoSessionRouter { private recordTargetSession( targetId: cdp.types.ts.Target.TargetID, sessionId: cdp.types.ts.Target.SessionID, - targetInfo: TargetInfo | RecordedTarget | null | undefined, + targetInfo: TargetInfo | ModCDPTopologyTarget | null | undefined, ): void { this.sessionId_from_targetId.set(targetId, sessionId); this.targetId_from_sessionId.set(sessionId, targetId); @@ -229,20 +480,83 @@ export class AutoSessionRouter { const targetId = eventTargetId ?? (sessionId ? (this.targetId_from_sessionId.get(sessionId) ?? null) : null); if (!targetId) return; if (sessionId && !this.execution_contexts.has(sessionId)) this.execution_contexts.set(sessionId, context.id); - const waiters = sessionId ? this.execution_context_waiters.get(sessionId) : null; + const auxData = context.auxData && typeof context.auxData === "object" ? context.auxData : {}; + const frameId = typeof auxData.frameId === "string" ? auxData.frameId : null; + const topologyContext: ModCDPTopologyExecutionContext = { + ...context, + id: context.id, + sessionId, + targetId, + frameId, + world: + context.name === piercerWorldName + ? "piercer" + : auxData.type === "default" + ? "main" + : context.name || String(auxData.type ?? "isolated"), + }; + this.contexts.set(this.contextKey(targetId, sessionId, context.id, context.uniqueId), topologyContext); + const waiterKey = sessionId ?? targetId; + const waiters = this.execution_context_waiters.get(waiterKey); if (!waiters) return; for (const waiter of [...waiters]) { + if (!waiter.matches(topologyContext)) continue; waiters.delete(waiter); clearTimeout(waiter.timeout); - waiter.resolve(context.id); + waiter.resolve(topologyContext); } - if (waiters.size === 0 && sessionId) this.execution_context_waiters.delete(sessionId); + if (waiters.size === 0) this.execution_context_waiters.delete(waiterKey); + } + + private findExecutionContext( + targetId: cdp.types.ts.Target.TargetID, + sessionId: cdp.types.ts.Target.SessionID | null, + frameId: cdp.types.ts.Page.FrameId, + selector: ContextSelector, + ): ModCDPTopologyExecutionContext | null { + for (const context of this.contexts.values()) { + if (context.targetId !== targetId || context.frameId !== frameId) continue; + if (sessionId != null && context.sessionId !== sessionId) continue; + if (selector.world === "piercer" && context.world === "piercer") return context; + if (selector.world === "isolated" && context.name === selector.worldName) return context; + if (selector.world === "main" && context.world === "main") return context; + if (context.world === selector.world) return context; + } + return null; + } + + private waitForExecutionContextMatching( + matches: (context: ModCDPTopologyExecutionContext) => boolean, + waiterKey: string | null, + timeoutMs = this.loopback_execution_context_timeout_ms, + ): Promise { + for (const context of this.contexts.values()) { + if (matches(context)) return Promise.resolve(context); + } + if (!waiterKey) return Promise.reject(new Error("Cannot wait for a Runtime execution context without a route.")); + return new Promise((resolve, reject) => { + const waiter: ExecutionContextWaiter = { + resolve, + reject, + matches, + timeout: setTimeout(() => { + const waiters = this.execution_context_waiters.get(waiterKey); + waiters?.delete(waiter); + if (waiters?.size === 0) this.execution_context_waiters.delete(waiterKey); + reject(new Error(`Timed out waiting for Runtime.executionContextCreated for route ${waiterKey}.`)); + }, timeoutMs), + }; + const waiters = this.execution_context_waiters.get(waiterKey); + if (waiters) waiters.add(waiter); + else this.execution_context_waiters.set(waiterKey, new Set([waiter])); + }); } private forgetTarget(targetId: cdp.types.ts.Target.TargetID): void { const sessionId = this.sessionId_from_targetId.get(targetId); if (sessionId) this.forgetSession(sessionId); this.targets.delete(targetId); + this.forgetExecutionContextsForRoute(targetId); } private forgetSession(sessionId: cdp.types.ts.Target.SessionID): void { @@ -264,10 +578,51 @@ export class AutoSessionRouter { routeKey: string, executionContextId: cdp.types.ts.Runtime.ExecutionContextId, ): void { + for (const [contextKey, context] of this.contexts) { + if ((context.sessionId === routeKey || context.targetId === routeKey) && context.id === executionContextId) { + this.contexts.delete(contextKey); + } + } if (this.execution_contexts.get(routeKey) === executionContextId) this.execution_contexts.delete(routeKey); } private forgetExecutionContextsForRoute(routeKey: string): void { + for (const [contextKey, context] of this.contexts) { + if (context.sessionId === routeKey || context.targetId === routeKey) this.contexts.delete(contextKey); + } this.execution_contexts.delete(routeKey); } + + private forgetExecutionContextsForFrame( + sessionId: cdp.types.ts.Target.SessionID | null, + targetId: cdp.types.ts.Target.TargetID | null, + frameId: cdp.types.ts.Page.FrameId, + ): void { + for (const [contextKey, context] of this.contexts) { + if (context.frameId !== frameId) continue; + if (sessionId != null && context.sessionId === sessionId) this.contexts.delete(contextKey); + else if (targetId != null && context.targetId === targetId) this.contexts.delete(contextKey); + } + } + + private contextKey( + targetId: cdp.types.ts.Target.TargetID, + sessionId: cdp.types.ts.Target.SessionID | null, + contextId: cdp.types.ts.Runtime.ExecutionContextId, + uniqueId: string | undefined, + ): string { + return uniqueId ?? `${sessionId ?? targetId}:${contextId}`; + } +} + +async function runTopologyQueue(items: Iterable, worker: (item: T) => Promise): Promise { + const queue = [...items]; + const workers = Array.from({ length: Math.min(topologyConcurrency, queue.length) }, async () => { + for (;;) { + const item = queue.shift(); + if (item == null) return; + await worker(item); + } + }); + await Promise.all(workers); } diff --git a/js/src/server/LoopbackCdpTransport.ts b/js/src/server/LoopbackCdpTransport.ts index 3a45c06f..d10fb38c 100644 --- a/js/src/server/LoopbackCdpTransport.ts +++ b/js/src/server/LoopbackCdpTransport.ts @@ -33,8 +33,8 @@ const target_auto_attach_params = { * request rejection, loopback event listener dispatch, loopback execution * context waits used by discovery, and loopback endpoint verification. It does * not choose ModCDP routes, manage custom command registries, run middleware, - * publish Stagehand/ModCDP events, or interpret browser-specific semantics - * beyond the narrow discovery probe needed to verify the current service worker. + * publish Stagehand/ModCDP events, or interpret topology beyond the narrow + * discovery probe needed to verify the current service worker. * * Lifecycle: * 1. The server constructs the transport with current config values. diff --git a/js/src/server/ModCDPServer.ts b/js/src/server/ModCDPServer.ts index fbd25b70..82940e09 100644 --- a/js/src/server/ModCDPServer.ts +++ b/js/src/server/ModCDPServer.ts @@ -868,6 +868,15 @@ export function installModCDPServer( }, }); + ModCDPServer.addCustomCommand({ + name: "Mod.getTopology", + handler: async (params: ProtocolParams = {}) => { + if (!ModCDPServer.router) setupServerUpstreamTransport(); + if (!ModCDPServer.router) throw new Error("ModCDP autorouter is not initialized."); + return await ModCDPServer.router.getTopology(params as Record); + }, + }); + ModCDPServer.addCustomCommand({ name: "Mod.addCustomCommand", handler: async (params: ProtocolParams = {}) => diff --git a/js/src/types/codegen.ts b/js/src/types/codegen.ts index 5060f398..e6b2fcd6 100755 --- a/js/src/types/codegen.ts +++ b/js/src/types/codegen.ts @@ -174,7 +174,7 @@ for (const d of domains) { } aliases.push(` Mod: {`); for (const base of modcdpCommands) { - const optional = base === "Ping"; + const optional = base === "Ping" || base === "GetTopology"; if (base === "AddCustomCommand") { aliases.push( ` addCustomCommand(name: TName, options?: ModCustomCommandOptions): Promise;`, diff --git a/js/src/types/generated/aliases.ts b/js/src/types/generated/aliases.ts index 24eedada..536673d7 100644 --- a/js/src/types/generated/aliases.ts +++ b/js/src/types/generated/aliases.ts @@ -1051,6 +1051,7 @@ export type CdpAliases = { addMiddleware(params: cdp.types.ts.Mod.AddMiddlewareParams): Promise; configure(params: cdp.types.ts.Mod.ConfigureParams): Promise; ping(params?: cdp.types.ts.Mod.PingParams): Promise; + getTopology(params?: cdp.types.ts.Mod.GetTopologyParams): Promise; }; }; @@ -2134,6 +2135,10 @@ export function createCdpAliases(send: CdpAliasSend, hooks: CdpAliasHooks = {}): const parsed = Mod.PingParams.parse(params ?? {}); return Mod.PingResponse.parse(await send("Mod.ping", parsed)); }, + getTopology: async (params?: unknown) => { + const parsed = Mod.GetTopologyParams.parse(params ?? {}); + return Mod.GetTopologyResponse.parse(await send("Mod.getTopology", parsed)); + }, }, }; } diff --git a/js/src/types/generated/cdp.ts b/js/src/types/generated/cdp.ts index 57305b51..422e2938 100644 --- a/js/src/types/generated/cdp.ts +++ b/js/src/types/generated/cdp.ts @@ -1,7 +1,7 @@ // Generated by types/codegen.ts from devtools-protocol@0.0.1621552. Do not edit by hand. import { zod, commands, events, types as runtimeTypes } from "./zod.js"; import type { z } from "zod"; -import type { ModCDPRoutes, ModCDPCustomPayload, ModCDPNamedValue, ModCDPName, ModCDPZodType, ModCDPPayloadShape, ModCDPPayloadSchemaSpec, ModCDPEvaluateParams, ModCDPAddCustomCommandParams, ModCDPAddCustomEventObjectParams, ModCDPAddCustomEventParams, ModCDPAddMiddlewareParams, ModCDPLauncherOptions, ModCDPUpstreamOptions, ModCDPClientOptions, ModCDPServerOptions, ModCDPConfigureParams, ModCDPPingParams, ModCDPPongEvent, ModCDPPingLatency, ModCDPCommandParams, ModCDPCommandResult, ModCDPEvaluateResponse, ModCDPAddCustomCommandResponse, ModCDPAddCustomEventResponse, ModCDPAddMiddlewareResponse, ModCDPConfigureResponse, ModCDPPingResponse, ModCDPBindingPayload, ModCDPCustomCommandRegistration, ModCDPCustomEventRegistration, ModCDPMiddlewareRegistration } from "../modcdp.js"; +import type { ModCDPRoutes, ModCDPCustomPayload, ModCDPNamedValue, ModCDPName, ModCDPZodType, ModCDPPayloadShape, ModCDPPayloadSchemaSpec, ModCDPEvaluateParams, ModCDPAddCustomCommandParams, ModCDPAddCustomEventObjectParams, ModCDPAddCustomEventParams, ModCDPAddMiddlewareParams, ModCDPLauncherOptions, ModCDPUpstreamOptions, ModCDPClientOptions, ModCDPServerOptions, ModCDPConfigureParams, ModCDPPingParams, ModCDPPongEvent, ModCDPPingLatency, ModCDPGetTopologyParams, ModCDPTopologyFrame, ModCDPTopologyDomRoot, ModCDPTopologyTarget, ModCDPTopologyExecutionContext, ModCDPTopology, ModCDPCommandParams, ModCDPCommandResult, ModCDPEvaluateResponse, ModCDPGetTopologyResponse, ModCDPAddCustomCommandResponse, ModCDPAddCustomEventResponse, ModCDPAddMiddlewareResponse, ModCDPConfigureResponse, ModCDPPingResponse, ModCDPBindingPayload, ModCDPCustomCommandRegistration, ModCDPCustomEventRegistration, ModCDPMiddlewareRegistration } from "../modcdp.js"; export const REQUEST = "request" as const; export const RESPONSE = "response" as const; @@ -43,9 +43,16 @@ export namespace cdp { export type PingParams = ModCDPPingParams; export type PongEvent = ModCDPPongEvent; export type PingLatency = ModCDPPingLatency; + export type GetTopologyParams = ModCDPGetTopologyParams; + export type TopologyFrame = ModCDPTopologyFrame; + export type TopologyDomRoot = ModCDPTopologyDomRoot; + export type TopologyTarget = ModCDPTopologyTarget; + export type TopologyExecutionContext = ModCDPTopologyExecutionContext; + export type Topology = ModCDPTopology; export type CommandParams = ModCDPCommandParams; export type CommandResult = ModCDPCommandResult; export type EvaluateResponse = ModCDPEvaluateResponse; + export type GetTopologyResponse = ModCDPGetTopologyResponse; export type AddCustomCommandResponse = ModCDPAddCustomCommandResponse; export type AddCustomEventResponse = ModCDPAddCustomEventResponse; export type AddMiddlewareResponse = ModCDPAddMiddlewareResponse; diff --git a/js/src/types/modcdp.ts b/js/src/types/modcdp.ts index b19e85ab..09476c69 100644 --- a/js/src/types/modcdp.ts +++ b/js/src/types/modcdp.ts @@ -221,8 +221,82 @@ export const ModCDPPingLatencySchema = z.object({ }); export type ModCDPPingLatency = z.infer; +export const ModCDPGetTopologyParamsSchema = z + .object({ + rootTargetId: z.string().optional(), + targetId: z.string().optional(), + active: z.boolean().optional(), + }) + .passthrough(); +export type ModCDPGetTopologyParams = z.infer; + +export const ModCDPTopologyFrameSchema = z + .object({ + targetId: z.string(), + url: z.string().nullable().optional(), + parentFrameId: z.string().nullable().optional(), + outerBackendNodeId: z.number().int().nullable().optional(), + }) + .passthrough(); +export type ModCDPTopologyFrame = z.infer; + +export const ModCDPTopologyDomRootSchema = z + .object({ + kind: z.enum(["document", "shadow"]), + frameId: z.string(), + outerBackendNodeId: z.number().int().nullable().optional(), + innerBackendNodeId: z.number().int().nullable().optional(), + mode: z.enum(["open", "closed", "user-agent"]).optional(), + executionContextId: z.number().int().optional(), + uniqueContextId: z.string().optional(), + }) + .passthrough(); +export type ModCDPTopologyDomRoot = z.infer; + +export const ModCDPTopologyTargetSchema = z + .object({ + targetId: z.string(), + type: z.string(), + title: z.string().optional(), + url: z.string().optional(), + attached: z.boolean().optional(), + parentId: z.string().optional(), + parentFrameId: z.string().optional(), + sessionId: z.string().nullable().optional(), + }) + .passthrough(); +export type ModCDPTopologyTarget = z.infer; + +export const ModCDPTopologyExecutionContextSchema = z + .object({ + id: z.number().int(), + origin: z.string().optional(), + name: z.string().optional(), + uniqueId: z.string().optional(), + auxData: z.record(z.string(), z.unknown()).optional(), + sessionId: z.string().nullable(), + targetId: z.string(), + frameId: z.string().nullable().optional(), + world: z.string(), + }) + .passthrough(); +export type ModCDPTopologyExecutionContext = z.infer; + +export const ModCDPTopologySchema = z + .object({ + objectGroup: z.string(), + rootFrameId: z.string(), + frames: z.record(z.string(), ModCDPTopologyFrameSchema), + roots: z.record(z.string(), ModCDPTopologyDomRootSchema), + targets: z.record(z.string(), ModCDPTopologyTargetSchema), + contexts: z.record(z.string(), ModCDPTopologyExecutionContextSchema), + }) + .passthrough(); +export type ModCDPTopology = z.infer; + export const ModCDPCommandParamsSchema = z.union([ ModCDPEvaluateParamsSchema, + ModCDPGetTopologyParamsSchema, ModCDPAddCustomCommandParamsSchema, ModCDPAddCustomEventParamsSchema, ModCDPAddMiddlewareParamsSchema, @@ -241,6 +315,9 @@ export type ModCDPCommandResult = z.infer; export const ModCDPEvaluateResponseSchema = z.unknown(); export type ModCDPEvaluateResponse = z.infer; +export const ModCDPGetTopologyResponseSchema = ModCDPTopologySchema; +export type ModCDPGetTopologyResponse = z.infer; + export const ModCDPAddCustomCommandResponseSchema = z .object({ name: z.string(), @@ -420,6 +497,7 @@ export const Mod = { PayloadShape: ModCDPPayloadShapeSchema, PayloadSchemaSpec: ModCDPPayloadSchemaSpecSchema, EvaluateParams: ModCDPEvaluateParamsSchema, + GetTopologyParams: ModCDPGetTopologyParamsSchema, AddCustomCommandParams: ModCDPAddCustomCommandParamsSchema, AddCustomEventObjectParams: ModCDPAddCustomEventObjectParamsSchema, AddCustomEventParams: ModCDPAddCustomEventParamsSchema, @@ -432,9 +510,15 @@ export const Mod = { PingParams: ModCDPPingParamsSchema, PongEvent: ModCDPPongEventSchema, PingLatency: ModCDPPingLatencySchema, + TopologyFrame: ModCDPTopologyFrameSchema, + TopologyDomRoot: ModCDPTopologyDomRootSchema, + TopologyTarget: ModCDPTopologyTargetSchema, + TopologyExecutionContext: ModCDPTopologyExecutionContextSchema, + Topology: ModCDPTopologySchema, CommandParams: ModCDPCommandParamsSchema, CommandResult: ModCDPCommandResultSchema, EvaluateResponse: ModCDPEvaluateResponseSchema, + GetTopologyResponse: ModCDPGetTopologyResponseSchema, AddCustomCommandResponse: ModCDPAddCustomCommandResponseSchema, AddCustomEventResponse: ModCDPAddCustomEventResponseSchema, AddMiddlewareResponse: ModCDPAddMiddlewareResponseSchema, diff --git a/js/test/test.ModCDPClientRoutedDefaultOverrides.ts b/js/test/test.ModCDPClientRoutedDefaultOverrides.ts index 20551ce7..c13022fd 100644 --- a/js/test/test.ModCDPClientRoutedDefaultOverrides.ts +++ b/js/test/test.ModCDPClientRoutedDefaultOverrides.ts @@ -158,6 +158,18 @@ test( "expected at least one page target to be matched to a chrome.tabs tab id", ); + const topology = await cdp.Mod.getTopology(); + assert.equal(typeof topology.rootFrameId, "string"); + assert.ok(topology.frames[topology.rootFrameId], "Mod.getTopology should include its root frame"); + assert.ok( + Object.values(topology.roots).some((root) => root.kind === "document"), + "Mod.getTopology should include at least one document root", + ); + assert.ok( + Object.values(topology.contexts).some((context) => context.world === "piercer"), + "Mod.getTopology should include a piercer execution context", + ); + await cdp.Mod.addCustomEvent({ name: cdp.Target.targetCreated }); const transformedEvents: cdp.types.ts.Target.TargetCreatedEvent[] = []; diff --git a/js/test/test.ServerUpstreamTransport.ts b/js/test/test.ServerUpstreamTransport.ts index 958bbdab..f542fe7d 100644 --- a/js/test/test.ServerUpstreamTransport.ts +++ b/js/test/test.ServerUpstreamTransport.ts @@ -8,7 +8,7 @@ import { ModCDPClient } from "../src/client/ModCDPClient.js"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const EXTENSION_PATH = path.resolve(HERE, "..", "..", "dist", "extension"); -test("loopback server upstream routes target commands through one transport", async () => { +test("loopback server upstream routes commands, events, and topology through one transport", async () => { const owner = new ModCDPClient({ launcher: { launcher_mode: "local", @@ -41,12 +41,19 @@ test("loopback server upstream routes target commands through one transport", as let targetId: string | null = null; try { await cdp.connect(); - const created = (await cdp.send("Target.createTarget", { url: targetTestUrl("loopback") })) as { + const created = (await cdp.send("Target.createTarget", { url: topologyTestUrl("loopback") })) as { targetId?: string; }; assert.equal(typeof created.targetId, "string"); targetId = created.targetId; await assertPageMarker(cdp, targetId, "loopback"); + + const topology = await cdp.Mod.getTopology({ targetId }); + assertTopology(topology, targetId); + assert.equal( + Object.values(topology.targets).some((target) => target.sessionId), + true, + ); } finally { if (targetId) await cdp.send("Target.closeTarget", { targetId }).catch(() => ({})); await cdp.close(); @@ -54,7 +61,7 @@ test("loopback server upstream routes target commands through one transport", as } }, 90_000); -test("chrome.debugger server upstream routes target commands through one transport", async () => { +test("chrome.debugger server upstream routes commands, events, and topology through one transport", async () => { const owner = new ModCDPClient({ launcher: { launcher_mode: "local", @@ -86,12 +93,19 @@ test("chrome.debugger server upstream routes target commands through one transpo let targetId: string | null = null; try { await cdp.connect(); - const created = (await cdp.send("Target.createTarget", { url: targetTestUrl("debugger") })) as { + const created = (await cdp.send("Target.createTarget", { url: topologyTestUrl("debugger") })) as { targetId?: string; }; assert.equal(typeof created.targetId, "string"); targetId = created.targetId; await assertPageMarker(cdp, targetId, "debugger"); + + const topology = await cdp.Mod.getTopology({ targetId }); + assertTopology(topology, targetId); + assert.equal( + Object.values(topology.targets).some((target) => target.targetId === targetId && target.sessionId == null), + true, + ); } finally { if (targetId) await cdp.send("Target.closeTarget", { targetId }).catch(() => ({})); await cdp.close(); @@ -112,10 +126,46 @@ async function assertPageMarker(cdp: ModCDPClient, targetId: string, label: stri }); } -function targetTestUrl(label: string) { +function assertTopology(topology: Awaited>, targetId: string) { + assert.equal(typeof topology.objectGroup, "string"); + assert.equal(typeof topology.rootFrameId, "string"); + assert.ok(topology.frames[topology.rootFrameId], "topology should include the root frame"); + assert.equal(topology.frames[topology.rootFrameId]?.targetId, targetId); + assert.ok(topology.targets[targetId], "topology should include the requested page target"); + + const contexts = Object.values(topology.contexts); + assert.ok( + contexts.some((context) => context.frameId === topology.rootFrameId && context.world === "piercer"), + "topology should include a piercer execution context for the root frame", + ); + + const roots = Object.values(topology.roots); + assert.ok( + roots.some((root) => root.kind === "document" && root.frameId === topology.rootFrameId), + "topology should include the root document", + ); + assert.ok( + roots.some((root) => root.kind === "shadow" && root.mode === "open"), + "topology should include open shadow roots", + ); + assert.ok( + roots.some((root) => root.kind === "shadow" && root.mode === "closed"), + "topology should include closed shadow roots", + ); +} + +function topologyTestUrl(label: string) { const html = ` - + +
+
+ + + `; return `data:text/html,${encodeURIComponent(html)}`; } diff --git a/python/examples/demo.py b/python/examples/demo.py index 3bea0f1b..dad81bcf 100644 --- a/python/examples/demo.py +++ b/python/examples/demo.py @@ -223,6 +223,23 @@ def on_pong(payload, *_): raise RuntimeError(f"unexpected Mod.evaluate result {modcdp_eval}") print(f"Mod.evaluate -> {modcdp_eval}") + topology_checked = False + if mode != "direct": + topology = expect_object(cdp.Mod.getTopology(), "Mod.getTopology") + root_frame_id = topology.get("rootFrameId") + frames = expect_object(topology.get("frames"), "Mod.getTopology.frames") + roots = expect_object(topology.get("roots"), "Mod.getTopology.roots") + contexts = expect_object(topology.get("contexts"), "Mod.getTopology.contexts") + if ( + not isinstance(root_frame_id, str) + or root_frame_id not in frames + or not any(isinstance(root, dict) and root.get("kind") == "document" for root in roots.values()) + or not any(isinstance(context, dict) and context.get("world") == "piercer" for context in contexts.values()) + ): + raise RuntimeError(f"unexpected Mod.getTopology result {topology}") + topology_checked = True + print(f"Mod.getTopology -> {{'rootFrameId': {root_frame_id!r}, 'frames': {len(frames)}, 'roots': {len(roots)}, 'contexts': {len(contexts)}}}") + response_middleware_registration = expect_object(cdp.send("Mod.addMiddleware", { "name": "Custom.echo", "phase": "response", @@ -285,7 +302,8 @@ def on_demo_event(payload, *_): raise RuntimeError(f"unexpected Runtime.evaluate result {runtime_eval}") print(f"Runtime.evaluate -> {runtime_eval}") - print(f"\nSUCCESS ({mode}/{upstream_mode}): native command, custom commands, custom event, and middleware all passed") + topology_label = "topology, " if topology_checked else "" + print(f"\nSUCCESS ({mode}/{upstream_mode}): native command, {topology_label}custom commands, custom event, and middleware all passed") # TTY-only: drop into a REPL where you can send live commands and # watch events as they print. Skip when run non-interactively so the diff --git a/python/modcdp/client/ModCDPClient.py b/python/modcdp/client/ModCDPClient.py index 4421e8b5..f34d364f 100644 --- a/python/modcdp/client/ModCDPClient.py +++ b/python/modcdp/client/ModCDPClient.py @@ -174,6 +174,9 @@ def configure(self, **params: Any) -> AwaitableDict | AwaitableValue: def ping(self, **params: Any) -> AwaitableDict | AwaitableValue: return self._client._send_command("Mod.ping", params) + def getTopology(self, **params: Any) -> AwaitableDict | AwaitableValue: + return self._client._send_command("Mod.getTopology", params) + MODCDP_READY_EXPRESSION = ( "Boolean(globalThis.ModCDP?.__ModCDPServerVersion >= 1 && " "globalThis.ModCDP?.handleCommand && globalThis.ModCDP?.addCustomEvent)" diff --git a/python/modcdp/extension.zip b/python/modcdp/extension.zip index 28d163a4..46f9d304 100644 Binary files a/python/modcdp/extension.zip and b/python/modcdp/extension.zip differ diff --git a/python/modcdp/types/modcdp.py b/python/modcdp/types/modcdp.py index 16417854..3851b64d 100644 --- a/python/modcdp/types/modcdp.py +++ b/python/modcdp/types/modcdp.py @@ -56,6 +56,61 @@ class ModCDPPingLatency(TypedDict): return_path_ms: int | float | None +class ModCDPGetTopologyParams(TypedDict, total=False): + rootTargetId: str + targetId: str + active: bool + + +class ModCDPTopologyFrame(TypedDict, total=False): + targetId: str + url: str | None + parentFrameId: str | None + outerBackendNodeId: int | None + + +class ModCDPTopologyDomRoot(TypedDict, total=False): + kind: Literal["document", "shadow"] + frameId: str + outerBackendNodeId: int | None + innerBackendNodeId: int | None + mode: Literal["open", "closed", "user-agent"] + executionContextId: int + uniqueContextId: str + + +class ModCDPTopologyTarget(TypedDict, total=False): + targetId: str + type: str + title: str + url: str + attached: bool + parentId: str + parentFrameId: str + sessionId: str | None + + +class ModCDPTopologyExecutionContext(TypedDict, total=False): + id: int + origin: str + name: str + uniqueId: str + auxData: dict[str, object] + sessionId: str | None + targetId: str + frameId: str | None + world: str + + +class ModCDPTopology(TypedDict): + objectGroup: str + rootFrameId: str + frames: dict[str, ModCDPTopologyFrame] + roots: dict[str, ModCDPTopologyDomRoot] + targets: dict[str, ModCDPTopologyTarget] + contexts: dict[str, ModCDPTopologyExecutionContext] + + class ModCDPConnectTiming(TypedDict): started_at: int upstream_mode: str | None diff --git a/python/tests/test_ModCDPClientRoutedDefaultOverrides.py b/python/tests/test_ModCDPClientRoutedDefaultOverrides.py index a973fc86..90c89d61 100644 --- a/python/tests/test_ModCDPClientRoutedDefaultOverrides.py +++ b/python/tests/test_ModCDPClientRoutedDefaultOverrides.py @@ -139,6 +139,16 @@ def test_service_worker_routed_standard_cdp_commands_and_events_can_be_transform ) ) + topology = cast(dict[str, Any], cdp.Mod.getTopology()) + root_frame_id = topology.get("rootFrameId") + frames = cast(dict[str, Any], topology.get("frames")) + roots = cast(dict[str, dict[str, Any]], topology.get("roots")) + contexts = cast(dict[str, dict[str, Any]], topology.get("contexts")) + self.assertIsInstance(root_frame_id, str) + self.assertIn(root_frame_id, frames) + self.assertTrue(any(root.get("kind") == "document" for root in roots.values())) + self.assertTrue(any(context.get("world") == "piercer" for context in contexts.values())) + cdp.Mod.addCustomEvent("Target.targetCreated") transformed_events: Queue[dict] = Queue()