diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts index 3e67d05..684194a 100644 --- a/packages/cli/src/commands/trace.ts +++ b/packages/cli/src/commands/trace.ts @@ -1,24 +1,44 @@ /** - * trace 命令 - 录制用户操作 + * trace 命令 - 录制操作 + 网络请求的统一时间线 */ -import type { Request } from "@bb-browser/shared"; +import type { Request, TraceEntry } from "@bb-browser/shared"; import { sendCommand } from "../client.js"; interface TraceOptions { json?: boolean; tabId?: string | number; + since?: number | string; + type?: string; + filter?: string; + limit?: number; + requestId?: string; } export async function traceCommand( - subCommand: 'start' | 'stop' | 'status', + subCommand: 'start' | 'stop' | 'status' | 'events' | 'body', options: TraceOptions = {} ): Promise { - const response = await sendCommand({ + const request: Record = { method: "trace", traceCommand: subCommand, tabId: options.tabId, - } as Request); + }; + + // events-specific params + if (subCommand === 'events') { + if (options.since !== undefined) request.since = Number(options.since); + if (options.type) request.traceType = options.type; + if (options.filter) request.filter = options.filter; + if (options.limit) request.limit = Number(options.limit); + } + + // body-specific params + if (subCommand === 'body') { + request.requestId = options.requestId; + } + + const response = await sendCommand(request as Request); if (options.json) { console.log(JSON.stringify(response)); @@ -34,70 +54,95 @@ export async function traceCommand( switch (subCommand) { case "start": { const status = data?.traceStatus; - console.log("开始录制用户操作"); - console.log(`标签页 ID: ${status?.tabId || 'N/A'}`); - console.log("\n在浏览器中进行操作,完成后运行 'bb-browser trace stop' 停止录制"); + console.log("Trace started"); + if (status?.tracedTabs?.length) { + console.log(`Tracing tabs: ${status.tracedTabs.join(', ')}`); + } + console.log("\nOperate the browser, then run 'bb-browser trace events' to see the timeline."); break; } case "stop": { - const events = data?.traceEvents || []; - - console.log(`录制完成,共 ${events.length} 个事件\n`); - + const status = data?.traceStatus; + console.log(`Trace stopped (${status?.eventCount ?? 0} events recorded)`); + console.log("Data preserved — use 'trace events' to query, 'trace start' to begin a new session."); + break; + } + + case "status": { + const status = data?.traceStatus; + if (status?.recording) { + console.log(`Recording (${status.eventCount} events)`); + if (status.tracedTabs?.length) { + console.log(`Tabs: ${status.tracedTabs.join(', ')}`); + } + } else if (status?.eventCount) { + console.log(`Stopped (${status.eventCount} events in buffer)`); + } else { + console.log("No active trace session"); + } + break; + } + + case "events": { + const events = (data?.traceEvents || []) as TraceEntry[]; + const cursor = (data as Record)?.cursor; + if (events.length === 0) { - console.log("没有录制到任何操作"); + console.log("No events"); break; } - - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const refStr = event.ref !== undefined ? `@${event.ref}` : ''; - - switch (event.type) { - case 'navigation': - console.log(`${i + 1}. 导航到: ${event.url}`); - break; - case 'click': - console.log(`${i + 1}. 点击 ${refStr} [${event.elementRole}] "${event.elementName || ''}"`); - break; - case 'fill': - console.log(`${i + 1}. 填充 ${refStr} [${event.elementRole}] "${event.elementName || ''}" <- "${event.value}"`); - break; - case 'select': - console.log(`${i + 1}. 选择 ${refStr} [${event.elementRole}] "${event.elementName || ''}" <- "${event.value}"`); + + // Print timeline + for (const e of events) { + const tabStr = `[${e.tab}]`; + switch (e.type) { + case 'action': { + const src = e.source === 'human' ? ' (human)' : ''; + const ref = e.ref !== undefined ? ` ref=${e.ref}` : ''; + const val = e.value ? ` "${e.value}"` : ''; + const key = e.key ? ` ${e.key}` : ''; + const info = e.text ? ` "${e.text}"` : ''; + const role = e.role ? ` [${e.role}]` : ''; + console.log(` ${e.seq} ${tabStr} action${src} ${e.action}${ref}${role}${info}${val}${key}`); break; - case 'check': - console.log(`${i + 1}. ${event.checked ? '勾选' : '取消勾选'} ${refStr} [${event.elementRole}] "${event.elementName || ''}"`); + } + case 'request': { + const trigger = e.triggerSeq ? ` trigger:${e.triggerSeq}` : ''; + const body = e.body ? ` (body: ${e.body.length}B)` : ''; + console.log(` ${e.seq} ${tabStr} request ${e.method} ${e.url}${body}${trigger}`); break; - case 'press': - console.log(`${i + 1}. 按键 ${event.key}`); + } + case 'response': { + const size = e.bodySize ? ` ${e.bodySize}B` : ''; + const mime = e.mimeType ? ` ${e.mimeType}` : ''; + console.log(` ${e.seq} ${tabStr} response ${e.requestId} → ${e.status}${mime}${size}`); break; - case 'scroll': - console.log(`${i + 1}. 滚动 ${event.direction} ${event.pixels}px`); + } + case 'navigation': { + const from = e.from ? ` (from: ${e.from})` : ''; + console.log(` ${e.seq} ${tabStr} navigation ${e.url}${from}`); break; - default: - console.log(`${i + 1}. ${event.type}`); + } } } - - const status = data?.traceStatus; - console.log(`\n状态: ${status?.recording ? '录制中' : '已停止'}`); + + console.log(`\n${events.length} events, cursor: ${cursor}`); break; } - case "status": { - const status = data?.traceStatus; - if (status?.recording) { - console.log(`录制中 (标签页 ${status.tabId})`); - console.log(`已录制 ${status.eventCount} 个事件`); + case "body": { + const body = data?.traceBody; + if (!body) { + console.error("No body data returned"); + break; + } + if (body.base64Encoded) { + console.log(`[base64 encoded, ${body.body.length} chars]`); } else { - console.log("未在录制"); + console.log(body.body); } break; } - - default: - throw new Error(`未知的 trace 子命令: ${subCommand}`); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 383f4cd..7f50e5c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,7 +38,7 @@ const TAB_REQUIRED_COMMANDS = new Set([ "snap", "screenshot", "get", "click", "hover", "fill", "type", "check", "uncheck", "select", "press", "scroll", "back", "forward", "reload", "close", - "frame", "dialog", "network", "console", "errors", "trace", + "frame", "dialog", "network", "console", "errors", ]); // eval requires --tab unless --domain is provided @@ -95,7 +95,11 @@ bb-browser - AI Agent 浏览器自动化工具 network requests [filter] 查看网络请求 (--tab required) console [--clear] 查看/清空控制台 (--tab required) errors [--clear] 查看/清空 JS 错误 (--tab required) - trace start|stop|status 录制用户操作 (--tab required) + trace start [--tab ] 录制操作+网络的统一时间线 + trace events [--type action|request] 查看时间线(支持 --since/--filter/--tab) + trace body 获取某个请求的 response body + trace stop 停止录制(数据保留) + trace status 查看录制状态 daemon [start|status|stop] 管理 daemon 选项: @@ -108,6 +112,11 @@ bb-browser - AI Agent 浏览器自动化工具 -d, --depth 限制树深度(snap 命令) -s, --selector 限定 CSS 选择器范围(snap 命令) --tab 指定操作的标签页 ID + --since 增量查询(network/console/errors/trace events) + --type 过滤事件类型(trace events: action/request/response) + --filter URL 或文字过滤(trace events/network requests) + --limit 限制返回条数 + --request-id 请求 ID(trace body) --help, -h 显示帮助信息 --version, -v 显示版本号 `.trim(); @@ -220,6 +229,30 @@ function parseArgs(argv: string[]): ParsedArgs { skipNext = true; } else if (arg === "--status") { skipNext = true; + } else if (arg === "--type") { + skipNext = true; + const nextIdx = args.indexOf(arg) + 1; + if (nextIdx < args.length) { + (result.flags as Record).type = args[nextIdx]; + } + } else if (arg === "--filter") { + skipNext = true; + const nextIdx = args.indexOf(arg) + 1; + if (nextIdx < args.length) { + (result.flags as Record).filter = args[nextIdx]; + } + } else if (arg === "--limit") { + skipNext = true; + const nextIdx = args.indexOf(arg) + 1; + if (nextIdx < args.length) { + (result.flags as Record).limit = parseInt(args[nextIdx], 10); + } + } else if (arg === "--request-id") { + skipNext = true; + const nextIdx = args.indexOf(arg) + 1; + if (nextIdx < args.length) { + (result.flags as Record)["request-id"] = args[nextIdx]; + } } else if (arg.startsWith("-")) { // Unknown flags, ignore } else if (result.command === null) { @@ -559,12 +592,21 @@ async function main(): Promise { } case "trace": { - const subCmd = parsed.args[0] as 'start' | 'stop' | 'status' | undefined; - if (!subCmd || !['start', 'stop', 'status'].includes(subCmd)) { - console.error("用法:bb-browser trace --tab "); + const subCmd = parsed.args[0] as 'start' | 'stop' | 'status' | 'events' | 'body' | undefined; + if (!subCmd || !['start', 'stop', 'status', 'events', 'body'].includes(subCmd)) { + console.error("用法:bb-browser trace [--tab ]"); process.exit(1); } - await traceCommand(subCmd, { json: parsed.flags.json, tabId: globalTabId }); + const f = parsed.flags as Record; + await traceCommand(subCmd, { + json: parsed.flags.json, + tabId: globalTabId, + since: globalSince, + type: f.type as string | undefined, + filter: f.filter as string | undefined, + limit: f.limit as number | undefined, + requestId: (f["request-id"] as string | undefined) || parsed.args[1], + }); break; } diff --git a/packages/daemon/src/cdp-connection.ts b/packages/daemon/src/cdp-connection.ts index c5a302e..1d1f5ae 100644 --- a/packages/daemon/src/cdp-connection.ts +++ b/packages/daemon/src/cdp-connection.ts @@ -442,8 +442,29 @@ export class CdpConnection { const sessionId = params.sessionId; const targetInfo = params.targetInfo as JsonObject; if (typeof sessionId === "string" && typeof targetInfo?.targetId === "string") { - this.sessions.set(targetInfo.targetId, sessionId); - this.attachedTargets.set(sessionId, targetInfo.targetId); + const targetId = targetInfo.targetId; + this.sessions.set(targetId, sessionId); + this.attachedTargets.set(sessionId, targetId); + + // Re-enable CDP domains on the new session. This handles same-tab + // navigation where Chrome detaches the old session and creates a new + // one — CDP domains (Network, Runtime, etc.) must be re-enabled. + const existingTab = this.tabManager.getTab(targetId); + if (existingTab) { + this.sessionCommand(targetId, "Page.enable").catch(() => {}); + this.sessionCommand(targetId, "Runtime.enable").catch(() => {}); + this.sessionCommand(targetId, "Network.enable").catch(() => {}); + this.sessionCommand(targetId, "DOM.enable").catch(() => {}); + this.sessionCommand(targetId, "Accessibility.enable").catch(() => {}); + + // Re-inject human capture if this tab is being traced + if (this.tabManager.isTraced(targetId)) { + this.enableHumanCapture(targetId).catch(() => {}); + } + } else { + // New target — register tab state + this.tabManager.addTab(targetId); + } } return; } @@ -456,10 +477,11 @@ export class CdpConnection { if (targetId) { this.sessions.delete(targetId); this.attachedTargets.delete(sessionId); - this.tabManager.removeTab(targetId); - if (this.currentTargetId === targetId) { - this.currentTargetId = undefined; - } + // Do NOT call tabManager.removeTab() here — detach can happen + // during same-tab navigation (cross-origin). The tab target still + // exists; only Target.targetDestroyed should remove tab state. + // Session maps are cleaned so the old sessionId won't route events, + // but the tab's event buffers (network, console, etc.) are preserved. } } return; @@ -470,7 +492,19 @@ export class CdpConnection { const params = message.params as JsonObject; const targetInfo = params.targetInfo as JsonObject; if (targetInfo?.type === "page" && typeof targetInfo.targetId === "string") { - this.attachAndEnable(targetInfo.targetId).catch(() => {}); + const newTargetId = targetInfo.targetId; + const openerId = typeof targetInfo.openerId === "string" ? targetInfo.openerId : undefined; + this.attachAndEnable(newTargetId).catch(() => {}); + + // Auto-join trace session if opener is being traced + const session = this.tabManager.traceSession; + if (session?.active && openerId && session.tracedTabs.has(openerId)) { + session.tracedTabs.add(newTargetId); + // Enable human capture on the new tab (delay to let attach complete) + setTimeout(() => { + this.enableHumanCapture(newTargetId).catch(() => {}); + }, 500); + } } return; } @@ -550,14 +584,36 @@ export class CdpConnection { const requestId = typeof params.requestId === "string" ? params.requestId : undefined; const request = params.request as JsonObject | undefined; if (!requestId || !request) return; + const url = String(request.url ?? ""); + const reqMethod = String(request.method ?? "GET"); + const resourceType = String(params.type ?? "Other"); + const headers = normalizeHeaders(request.headers); + const body = typeof request.postData === "string" ? request.postData : undefined; tab.addNetworkRequest(requestId, { - url: String(request.url ?? ""), - method: String(request.method ?? "GET"), - type: String(params.type ?? "Other"), + url, + method: reqMethod, + type: resourceType, timestamp: Math.round(Number(params.timestamp ?? Date.now()) * 1000), - requestHeaders: normalizeHeaders(request.headers), - requestBody: typeof request.postData === "string" ? request.postData : undefined, + requestHeaders: headers, + requestBody: body, }); + // Push to trace timeline + if (this.tabManager.isTraced(targetId)) { + const now = Date.now(); + this.tabManager.tracePush({ + seq: this.tabManager.nextSeq(), + ts: now, + tab: tab.shortId, + type: 'request', + requestId, + method: reqMethod, + url, + resourceType, + headers, + body, + triggerSeq: this.tabManager.inferTriggerSeq(now), + }); + } return; } @@ -565,12 +621,26 @@ export class CdpConnection { const requestId = typeof params.requestId === "string" ? params.requestId : undefined; const response = params.response as JsonObject | undefined; if (!requestId || !response) return; + const status = typeof response.status === "number" ? response.status : 0; + const mimeType = typeof response.mimeType === "string" ? response.mimeType : undefined; tab.updateNetworkResponse(requestId, { status: typeof response.status === "number" ? response.status : undefined, statusText: typeof response.statusText === "string" ? response.statusText : undefined, responseHeaders: normalizeHeaders(response.headers), - mimeType: typeof response.mimeType === "string" ? response.mimeType : undefined, + mimeType, }); + // Push to trace timeline + if (this.tabManager.isTraced(targetId)) { + this.tabManager.tracePush({ + seq: this.tabManager.nextSeq(), + ts: Date.now(), + tab: tab.shortId, + type: 'response', + requestId, + status, + mimeType, + }); + } return; } @@ -656,6 +726,31 @@ export class CdpConnection { timestamp: Date.now(), }); } + + // Human action capture via Runtime.bindingCalled + if (method === "Runtime.bindingCalled") { + const name = params.name; + const payload = params.payload; + if (name === "__bb_trace" && typeof payload === "string" && this.tabManager.isTraced(targetId)) { + try { + const data = JSON.parse(payload) as Record; + this.tabManager.tracePush({ + seq: this.tabManager.nextSeq(), + ts: Date.now(), + tab: tab.shortId, + type: 'action', + source: 'human', + action: String(data.action ?? 'unknown'), + selector: typeof data.selector === 'string' ? data.selector : undefined, + text: typeof data.text === 'string' ? data.text : undefined, + role: typeof data.role === 'string' ? data.role : undefined, + tag: typeof data.tag === 'string' ? data.tag : undefined, + value: typeof data.value === 'string' ? data.value : undefined, + key: typeof data.key === 'string' ? data.key : undefined, + }); + } catch {} + } + } } // --------------------------------------------------------------------------- @@ -755,6 +850,57 @@ export class CdpConnection { return result.sessionId; } + // --------------------------------------------------------------------------- + // Human action capture for trace + // --------------------------------------------------------------------------- + + private static readonly HUMAN_CAPTURE_SCRIPT = `(function() { + if (window.__bb_trace_installed) return; + window.__bb_trace_installed = true; + function report(data) { + try { window.__bb_trace(JSON.stringify(data)); } catch(e) {} + } + function meta(el) { + if (!el || !el.tagName) return {}; + return { + tag: el.tagName, + text: (el.innerText || '').slice(0, 80).trim(), + role: el.getAttribute ? el.getAttribute('role') : undefined, + selector: el.id ? '#' + el.id + : el.className && typeof el.className === 'string' ? el.tagName.toLowerCase() + '.' + el.className.split(' ')[0] + : el.tagName.toLowerCase(), + }; + } + document.addEventListener('click', function(e) { + report({ action: 'click', ...meta(e.target), x: e.clientX, y: e.clientY }); + }, true); + document.addEventListener('input', function(e) { + report({ action: 'input', ...meta(e.target), value: e.target.value }); + }, true); + document.addEventListener('keydown', function(e) { + if (['Enter','Tab','Escape','Backspace','Delete'].indexOf(e.key) >= 0 || e.ctrlKey || e.metaKey) { + report({ action: 'press', key: e.key, ...meta(e.target) }); + } + }, true); + document.addEventListener('submit', function(e) { + report({ action: 'submit', ...meta(e.target) }); + }, true); + })();`; + + /** Inject human action capture listeners into a tab via CDP binding. */ + async enableHumanCapture(targetId: string): Promise { + try { + await this.sessionCommand(targetId, "Runtime.addBinding", { name: "__bb_trace" }); + } catch { + // Binding may already exist + } + await this.sessionCommand(targetId, "Page.addScriptToEvaluateOnNewDocument", { + source: CdpConnection.HUMAN_CAPTURE_SCRIPT, + }).catch(() => {}); + // Also evaluate immediately for the current page + await this.evaluate(targetId, CdpConnection.HUMAN_CAPTURE_SCRIPT, false).catch(() => {}); + } + /** Get all targets via CDP Target.getTargets. */ async getTargets(): Promise { const result = await this.browserCommand<{ diff --git a/packages/daemon/src/command-dispatch.ts b/packages/daemon/src/command-dispatch.ts index ee15fd5..2049e90 100644 --- a/packages/daemon/src/command-dispatch.ts +++ b/packages/daemon/src/command-dispatch.ts @@ -15,11 +15,12 @@ import type { ResponseData, RefInfo, SnapshotData, - TraceEvent, + TraceEntry, TraceStatus, } from "@bb-browser/shared"; import { CdpConnection, type CdpTargetInfo } from "./cdp-connection.js"; import type { TabState } from "./tab-state.js"; +import type { ActionDetail } from "./tab-state.js"; import { getAllSites, executeSiteAdapter } from "./site-adapter.js"; // --------------------------------------------------------------------------- @@ -497,12 +498,7 @@ async function getAttributeValue( return String(call.result.value ?? ""); } -// --------------------------------------------------------------------------- -// Trace state (global, not per-tab — matches original behavior) -// --------------------------------------------------------------------------- - -let traceRecording = false; -const traceEvents: TraceEvent[] = []; +// Trace state is now managed by TabStateManager.traceSession // --------------------------------------------------------------------------- // Domain-based tab routing @@ -604,7 +600,7 @@ export async function dispatchRequest( tabId: created.targetId, url, tab: newTab?.shortId ?? created.targetId.slice(-4).toLowerCase(), - seq: newTab?.recordAction(), + seq: newTab?.recordAction({ action: 'tab_new', url: url }), }); } @@ -614,7 +610,7 @@ export async function dispatchRequest( if (!request.script) return fail("Missing script parameter"); try { const resolved = await resolveTabByDomain(cdp, request.domain); - const seq = resolved.tab.recordAction(); + const seq = resolved.tab.recordAction({ action: 'eval', url: request.domain }); let script = request.script; if (request.args !== undefined) { @@ -643,7 +639,7 @@ export async function dispatchRequest( // ----------------------------------------------------------------------- case "open": { if (!request.url) return fail("Missing url parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'open', url: request.url }); if (tabRef === undefined) { // No specific tab requested — open in new tab const created = await cdp.browserCommand<{ targetId: string }>( @@ -671,25 +667,25 @@ export async function dispatchRequest( } case "back": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'back' }); await cdp.evaluate(target.id, "history.back(); undefined"); return ok({ tab: shortId, seq }); } case "forward": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'forward' }); await cdp.evaluate(target.id, "history.forward(); undefined"); return ok({ tab: shortId, seq }); } case "reload": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'reload' }); await cdp.sessionCommand(target.id, "Page.reload", { ignoreCache: false }); return ok({ tab: shortId, seq }); } case "close": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'close' }); await cdp.browserCommand("Target.closeTarget", { targetId: target.id }); tab.refs = {}; return ok({ tab: shortId, seq }); @@ -735,7 +731,14 @@ export async function dispatchRequest( case "click": case "hover": { if (!request.ref) return fail("Missing ref parameter"); - const seq = tab.recordAction(); + const refInfo = tab.refs[request.ref]; + const seq = tab.recordAction({ + action: request.method, + ref: Number(request.ref), + text: refInfo?.name?.slice(0, 80), + role: refInfo?.role, + tag: refInfo?.tagName, + }); const backendNodeId = await parseRef(cdp, target.id, tab, request.ref); const point = await getInteractablePoint(cdp, target.id, backendNodeId); await cdp.sessionCommand(target.id, "Input.dispatchMouseEvent", { @@ -751,7 +754,15 @@ export async function dispatchRequest( case "type": { if (!request.ref) return fail("Missing ref parameter"); if (request.text == null) return fail("Missing text parameter"); - const seq = tab.recordAction(); + const fillRefInfo = tab.refs[request.ref]; + const seq = tab.recordAction({ + action: request.method, + ref: Number(request.ref), + value: request.text, + text: fillRefInfo?.name?.slice(0, 80), + role: fillRefInfo?.role, + tag: fillRefInfo?.tagName, + }); const backendNodeId = await parseRef(cdp, target.id, tab, request.ref); await insertTextIntoNode(cdp, target.id, backendNodeId, request.text, request.method === "fill"); return ok({ @@ -764,7 +775,7 @@ export async function dispatchRequest( case "check": case "uncheck": { if (!request.ref) return fail("Missing ref parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: request.method, ref: Number(request.ref) }); const desired = request.method === "check"; const backendNodeId = await parseRef(cdp, target.id, tab, request.ref); const resolved = await cdp.sessionCommand<{ object: { objectId: string } }>( @@ -781,7 +792,7 @@ export async function dispatchRequest( case "select": { if (!request.ref || request.value == null) return fail("Missing ref or value parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'select', ref: Number(request.ref), value: request.value }); const backendNodeId = await parseRef(cdp, target.id, tab, request.ref); const resolved = await cdp.sessionCommand<{ object: { objectId: string } }>( target.id, @@ -825,7 +836,7 @@ export async function dispatchRequest( case "press": { if (!request.key) return fail("Missing key parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'press', key: request.key }); await cdp.sessionCommand(target.id, "Input.dispatchKeyEvent", { type: "keyDown", key: request.key, }); @@ -841,7 +852,7 @@ export async function dispatchRequest( } case "scroll": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'scroll', direction: request.direction }); const pixels = request.pixels ?? 300; let deltaX = 0; let deltaY = 0; @@ -862,7 +873,7 @@ export async function dispatchRequest( // ensurePageTarget() above. This branch handles eval with an explicit // tab, or eval without domain. if (!request.script) return fail("Missing script parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'eval' }); // If args are provided, wrap the script in an IIFE that receives them. let script = request.script; @@ -904,7 +915,7 @@ export async function dispatchRequest( // ----------------------------------------------------------------------- case "frame": { if (!request.selector) return fail("Missing selector parameter"); - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'frame' }); const document = await cdp.pageCommand<{ root: { nodeId: number } }>( target.id, "DOM.getDocument", @@ -944,7 +955,7 @@ export async function dispatchRequest( } case "frame_main": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'frame_main' }); tab.activeFrameId = null; return ok({ frameInfo: { frameId: 0 }, @@ -957,7 +968,7 @@ export async function dispatchRequest( // Dialog // ----------------------------------------------------------------------- case "dialog": { - const seq = tab.recordAction(); + const seq = tab.recordAction({ action: 'dialog' }); tab.dialogHandler = { accept: request.dialogResponse !== "dismiss", ...(request.promptText !== undefined ? { promptText: request.promptText } : {}), @@ -1085,27 +1096,122 @@ export async function dispatchRequest( // ----------------------------------------------------------------------- case "trace": { const subCommand = request.traceCommand ?? "status"; + const tm = cdp.tabManager; switch (subCommand) { - case "start": - traceRecording = true; - traceEvents.length = 0; + case "start": { + // Resolve target tab if specified + let traceTargetId: string | undefined; + if (tabRef !== undefined) { + traceTargetId = target.id; + } + const session = tm.traceStart(traceTargetId); + // Enable human action capture on traced tabs + for (const tid of session.tracedTabs) { + cdp.enableHumanCapture(tid).catch(() => {}); + } + const tracedShortIds = Array.from(session.tracedTabs) + .map(tid => tm.getTabShortId(tid)) + .filter(Boolean) as string[]; return ok({ - traceStatus: { recording: true, eventCount: 0 } satisfies TraceStatus, + traceStatus: { recording: true, eventCount: 0, tracedTabs: tracedShortIds } satisfies TraceStatus, tab: shortId, }); + } case "stop": { - traceRecording = false; + const session = tm.traceSession; + tm.traceStop(); return ok({ - traceEvents: [...traceEvents], - traceStatus: { recording: false, eventCount: traceEvents.length } satisfies TraceStatus, + traceStatus: { + recording: false, + eventCount: session?.timeline.length ?? 0, + tracedTabs: session ? Array.from(session.tracedTabs).map(tid => tm.getTabShortId(tid)).filter(Boolean) as string[] : [], + } satisfies TraceStatus, tab: shortId, }); } - case "status": + case "status": { + const session = tm.traceSession; return ok({ - traceStatus: { recording: traceRecording, eventCount: traceEvents.length } satisfies TraceStatus, + traceStatus: { + recording: session?.active ?? false, + eventCount: session?.timeline.length ?? 0, + tracedTabs: session ? Array.from(session.tracedTabs).map(tid => tm.getTabShortId(tid)).filter(Boolean) as string[] : [], + } satisfies TraceStatus, tab: shortId, }); + } + case "events": { + const session = tm.traceSession; + if (!session) return ok({ traceEvents: [], cursor: 0, tab: shortId }); + + let events: TraceEntry[] = [...session.timeline]; + + // Filter by tab + if (request.tabId !== undefined) { + const filterTab = String(request.tabId); + events = events.filter(e => e.tab === filterTab); + } + + // Filter by type + if (request.traceType) { + events = events.filter(e => e.type === request.traceType); + } + + // Incremental query (since) + if (request.since !== undefined) { + const threshold = typeof request.since === 'number' ? request.since : 0; + events = events.filter(e => e.seq > threshold); + } + + // Text/URL filter + if (request.filter) { + const f = request.filter.toLowerCase(); + events = events.filter(e => { + if (e.type === 'request') return e.url.toLowerCase().includes(f); + if (e.type === 'navigation') return e.url.toLowerCase().includes(f); + if (e.type === 'action') return (e.text || e.action || '').toLowerCase().includes(f); + return false; + }); + } + + // Limit + if (request.limit !== undefined && request.limit > 0 && events.length > request.limit) { + events = events.slice(-request.limit); + } + + const cursor = events.length > 0 ? events[events.length - 1].seq : (typeof request.since === 'number' ? request.since : 0); + return ok({ traceEvents: events, cursor, tab: shortId }); + } + case "body": { + if (!request.requestId) return fail("Missing requestId parameter"); + const session = tm.traceSession; + if (!session) return fail("No trace session"); + + // Find the request entry to get its tab + const reqEntry = session.timeline.find( + e => e.type === 'request' && e.requestId === request.requestId, + ); + if (!reqEntry || reqEntry.type !== 'request') return fail(`Request ${request.requestId} not found in trace`); + + // Resolve the tab's targetId to fetch the body + const reqTabShortId = reqEntry.tab; + const reqTargetId = tm.resolveShortId(reqTabShortId); + if (!reqTargetId) return fail(`Tab ${reqTabShortId} no longer exists`); + + try { + const body = await cdp.sessionCommand<{ body: string; base64Encoded: boolean }>( + reqTargetId, + "Network.getResponseBody", + { requestId: request.requestId }, + ); + return ok({ + traceBody: { requestId: request.requestId, body: body.body, base64Encoded: body.base64Encoded }, + tab: shortId, + }); + } catch (error) { + return fail(error); + } + } default: return fail(`Unknown trace subcommand: ${subCommand}`); } diff --git a/packages/daemon/src/tab-state.ts b/packages/daemon/src/tab-state.ts index 7d7aa4c..520033e 100644 --- a/packages/daemon/src/tab-state.ts +++ b/packages/daemon/src/tab-state.ts @@ -16,9 +16,36 @@ import type { ConsoleMessageInfo, JSErrorInfo, RefInfo, + TraceEntry, + TraceAction, } from "@bb-browser/shared"; import { RingBuffer } from "./ring-buffer.js"; +// --------------------------------------------------------------------------- +// Trace session — lives on TabStateManager, spans multiple tabs +// --------------------------------------------------------------------------- + +export interface TraceSession { + /** Whether recording is active */ + active: boolean; + /** Set of targetIds being traced */ + tracedTabs: Set; + /** Unified timeline of all events across traced tabs */ + timeline: TraceEntry[]; + /** Seq at the start of recording */ + startSeq: number; +} + +/** Detail passed to recordAction when trace is active */ +export interface ActionDetail { + action: string; + ref?: number; + value?: string; + key?: string; + direction?: string; + url?: string; +} + // --------------------------------------------------------------------------- // Seq-tagged event wrappers // --------------------------------------------------------------------------- @@ -58,6 +85,9 @@ export class TabState { /** Dialog auto-handler config. */ dialogHandler: { accept: boolean; promptText?: string } | null = null; + /** Reference to the manager (set after construction) */ + manager!: TabStateManager; + constructor( targetId: string, shortId: string, @@ -69,10 +99,31 @@ export class TabState { // --------------- Action seq --------------- - /** Increment global seq and record it as this tab's last action. */ - recordAction(): number { + /** Increment global seq and record it as this tab's last action. + * When trace is active, pushes a TraceAction to the timeline. */ + recordAction(detail?: ActionDetail): number { const seq = this.nextSeq(); this.lastActionSeq = seq; + + // Push to trace timeline if this tab is being traced + const session = this.manager?.traceSession; + if (session?.active && session.tracedTabs.has(this.targetId) && detail) { + const entry: TraceAction = { + seq, + ts: Date.now(), + tab: this.shortId, + type: 'action', + source: 'command', + action: detail.action, + ref: detail.ref, + value: detail.value, + key: detail.key, + direction: detail.direction, + url: detail.url, + }; + session.timeline.push(entry); + } + return seq; } @@ -266,6 +317,9 @@ export class TabStateManager { private shortToTarget = new Map(); // shortId -> targetId private targetToShort = new Map(); // targetId -> shortId + /** Active trace session (at most one at a time) */ + traceSession: TraceSession | null = null; + /** Generate a globally unique short ID for a target. */ private generateShortId(targetId: string): string { for (let len = 4; len <= targetId.length; len++) { @@ -295,6 +349,7 @@ export class TabStateManager { const shortId = this.generateShortId(targetId); const tab = new TabState(targetId, shortId, () => this.nextSeq()); + tab.manager = this; this.tabs.set(targetId, tab); this.shortToTarget.set(shortId, targetId); this.targetToShort.set(targetId, shortId); @@ -334,4 +389,60 @@ export class TabStateManager { get tabCount(): number { return this.tabs.size; } + + // --------------- Trace session helpers --------------- + + /** Start a new trace session. If a targetId is given, only trace that tab + * (plus any tabs it opens). If omitted, trace all current and future tabs. */ + traceStart(targetId?: string): TraceSession { + const tracedTabs = new Set(); + if (targetId) { + tracedTabs.add(targetId); + } else { + for (const tid of this.tabs.keys()) tracedTabs.add(tid); + } + this.traceSession = { + active: true, + tracedTabs, + timeline: [], + startSeq: this.seq, + }; + return this.traceSession; + } + + /** Stop the current trace session. Data is preserved for querying. */ + traceStop(): void { + if (this.traceSession) { + this.traceSession.active = false; + } + } + + /** Push a trace entry (used by cdp-connection for network/navigation events). */ + tracePush(entry: TraceEntry): void { + this.traceSession?.timeline.push(entry); + } + + /** Check if a target is being traced. */ + isTraced(targetId: string): boolean { + const s = this.traceSession; + return !!s?.active && s.tracedTabs.has(targetId); + } + + /** Get shortId for a tab being traced, for building trace entries. */ + getTabShortId(targetId: string): string | undefined { + return this.tabs.get(targetId)?.shortId; + } + + /** Infer which action likely triggered a network request (heuristic). */ + inferTriggerSeq(requestTs: number): number | undefined { + const tl = this.traceSession?.timeline; + if (!tl) return undefined; + for (let i = tl.length - 1; i >= 0; i--) { + const e = tl[i]; + if (e.type !== 'action') continue; + if (requestTs - e.ts < 2000) return e.seq; + break; // action is too old + } + return undefined; + } } diff --git a/packages/shared/src/commands.ts b/packages/shared/src/commands.ts index 3dd6bcd..5169908 100644 --- a/packages/shared/src/commands.ts +++ b/packages/shared/src/commands.ts @@ -344,11 +344,16 @@ export const COMMANDS: CommandDef[] = [ }, { method: "trace", group: "debug", - description: "Record user interactions for replay or code generation", - requiresTab: true, + description: "Record a unified timeline of actions + network requests for site adapter creation", + requiresTab: false, params: { - traceCommand: { type: "string", required: true, position: 0, description: "Trace sub-command (start/stop/status)" }, - tab: { type: "string", required: true, description: "Tab short ID" }, + traceCommand: { type: "string", required: true, position: 0, description: "Trace sub-command (start/stop/status/events/body)" }, + tab: { type: "string", required: false, description: "Tab short ID (start: trace only this tab; events: filter by tab)" }, + since: { type: "string", required: false, description: "Incremental query cursor (seq number)" }, + type: { type: "string", required: false, description: "Filter by event type: action, request, response, navigation" }, + filter: { type: "string", required: false, description: "URL or text substring filter" }, + limit: { type: "number", required: false, description: "Max number of events to return" }, + requestId: { type: "string", required: false, description: "Request ID (for trace body)" }, }, }, ]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ce77da6..27b1c4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,7 +16,11 @@ export { type ResponseError, type SnapshotData, type TabInfo, - type TraceEvent, + type TraceEntry, + type TraceAction, + type TraceRequest, + type TraceResponse, + type TraceNavigation, type TraceStatus, } from "./protocol.js"; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 1cb0720..e8e80fb 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -91,8 +91,12 @@ export interface Request { consoleCommand?: "get" | "clear"; /** errors 子命令:get, clear */ errorsCommand?: "get" | "clear"; - /** trace 子命令:start, stop, status */ - traceCommand?: "start" | "stop" | "status"; + /** trace 子命令:start, stop, status, events, body */ + traceCommand?: "start" | "stop" | "status" | "events" | "body"; + /** Request ID for trace body command */ + requestId?: string; + /** Event type filter for trace events (action/request/response/navigation) */ + traceType?: string; /** 按键名(press 命令使用) */ key?: string; /** 修饰键列表(press 命令使用) */ @@ -198,49 +202,91 @@ export interface JSErrorInfo { timestamp: number; } -/** Trace 事件类型 - 录制用户操作 */ -export interface TraceEvent { - /** 事件类型 */ - type: 'click' | 'fill' | 'select' | 'check' | 'press' | 'scroll' | 'navigation'; - /** 时间戳 */ - timestamp: number; - /** 事件发生时的页面 URL */ - url: string; - - /** 元素引用 - highlightIndex,可直接用于 @ref */ +// --------------------------------------------------------------------------- +// Trace — unified timeline of actions + network + navigation +// --------------------------------------------------------------------------- + +/** Base fields shared by all trace entries */ +interface TraceEntryBase { + /** Global monotonic seq */ + seq: number; + /** Millisecond timestamp (Date.now()) */ + ts: number; + /** Tab shortId where the event occurred */ + tab: string; +} + +/** User action (bb-browser command or human interaction) */ +export interface TraceAction extends TraceEntryBase { + type: 'action'; + /** Whether this action came from a bb-browser command or human interaction */ + source: 'command' | 'human'; + /** Action name: click, fill, type, press, scroll, select, check, open, ... */ + action: string; + /** Element ref from snapshot */ ref?: number; - /** 备用定位 - XPath */ - xpath?: string; - /** CSS 选择器 */ - cssSelector?: string; - - /** 操作参数 - fill/select 的值 */ + /** CSS selector (for human-captured events) */ + selector?: string; + /** Visible text of the target element (truncated) */ + text?: string; + /** Accessibility role */ + role?: string; + /** HTML tag name */ + tag?: string; + /** Input value (fill/type) */ value?: string; - /** 操作参数 - press 的按键 */ + /** Key name (press) */ key?: string; - /** 操作参数 - scroll 方向 */ - direction?: 'up' | 'down' | 'left' | 'right'; - /** 操作参数 - scroll 距离 */ - pixels?: number; - /** 操作参数 - check/uncheck 状态 */ - checked?: boolean; - - /** 语义信息 - 元素角色 */ - elementRole?: string; - /** 语义信息 - 元素名称 */ - elementName?: string; - /** 语义信息 - 元素标签 */ - elementTag?: string; + /** Scroll direction */ + direction?: string; + /** URL (for open/navigation actions) */ + url?: string; +} + +/** Network request sent */ +export interface TraceRequest extends TraceEntryBase { + type: 'request'; + requestId: string; + method: string; + url: string; + /** Resource type: XHR, Fetch, Document, Script, ... */ + resourceType: string; + headers?: Record; + /** POST body */ + body?: string; + /** Seq of the action that likely triggered this request */ + triggerSeq?: number; +} + +/** Network response received */ +export interface TraceResponse extends TraceEntryBase { + type: 'response'; + /** Matches TraceRequest.requestId */ + requestId: string; + status: number; + mimeType?: string; + bodySize?: number; +} + +/** Page navigation */ +export interface TraceNavigation extends TraceEntryBase { + type: 'navigation'; + url: string; + /** URL before navigation */ + from?: string; } -/** Trace 录制状态 */ +/** Union of all trace entry types */ +export type TraceEntry = TraceAction | TraceRequest | TraceResponse | TraceNavigation; + +/** Trace session status */ export interface TraceStatus { - /** 是否正在录制 */ + /** Whether recording is active */ recording: boolean; - /** 已录制事件数量 */ + /** Total event count in the timeline */ eventCount: number; - /** 录制的标签页 ID */ - tabId?: number; + /** Tabs being traced */ + tracedTabs?: string[]; } /** 响应数据 */ @@ -299,10 +345,12 @@ export interface ResponseData { consoleMessages?: ConsoleMessageInfo[]; /** JS 错误列表(errors 命令返回) */ jsErrors?: JSErrorInfo[]; - /** Trace 事件列表(trace stop 命令返回) */ - traceEvents?: TraceEvent[]; - /** Trace 录制状态(trace status 命令返回) */ + /** Trace timeline entries (trace events/stop command) */ + traceEvents?: TraceEntry[]; + /** Trace session status */ traceStatus?: TraceStatus; + /** Trace response body (trace body command) */ + traceBody?: { requestId: string; body: string; base64Encoded: boolean }; } /** 错误信息 */