diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-07 12:54:12 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-07 12:54:12 +0530 |
| commit | 4052e6e6afef1dc20146765963dea7c0289ca401 (patch) | |
| tree | 3734869a41404e6ebcc85d35789aea4684d8e5b0 /src/cdp | |
| parent | bd197399048212112d2337b4799ebe20de01a6e8 (diff) | |
release: v0.5.0 — browser_console tool for JS error/console diagnostics
Diffstat (limited to 'src/cdp')
| -rw-r--r-- | src/cdp/console-buffer.ts | 265 | ||||
| -rw-r--r-- | src/cdp/session.ts | 11 |
2 files changed, 275 insertions, 1 deletions
diff --git a/src/cdp/console-buffer.ts b/src/cdp/console-buffer.ts new file mode 100644 index 0000000..002ccd6 --- /dev/null +++ b/src/cdp/console-buffer.ts @@ -0,0 +1,265 @@ +/** + * In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events. + * + * Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw + * params to ingestConsoleApi / ingestLogEntry; tools later read records via drain(). + * + * Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO, + * and the overflow flag is reported once per drain so tools can warn the LLM that + * data may be missing. + * + * Each record carries a monotonic `seq`. Tools expose the last drained seq as + * `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since + * the previous drain. This is the cursor pattern that makes "what did this action + * cause?" answerable in one tool call. + * + * Pure module (no CDP dependency) — joining/filtering logic stays trivially testable. + */ + +export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug"; + +export type ConsoleRecord = { + seq: number; + /** Wall-clock ms when the event was ingested, in Date.now() units. */ + timestampMs: number; + level: ConsoleLevel; + /** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */ + text: string; + source: "console-api" | "log-entry"; + url?: string; + lineNumber?: number; + /** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */ + stackTrace?: string; +}; + +export type ConsoleFilter = { + levels?: ConsoleLevel[]; + /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */ + textPattern?: string; + /** Only records with seq strictly greater than this. */ + sinceSeq?: number; + /** Only records with timestamp at most N ms ago. */ + sinceMs?: number; + /** Cap on returned records (default 50, max 500). */ + limit?: number; +}; + +export type ConsoleDrainResult = { + readonly records: ConsoleRecord[]; + /** Total matches before `limit` was applied. */ + readonly total: number; + /** True if the ring buffer evicted at least one record since the last drain. */ + readonly bufferOverflowed: boolean; +}; + +export type ConsoleBuffer = { + ingestConsoleApi(p: unknown): void; + ingestLogEntry(p: unknown): void; + drain(filter: ConsoleFilter): ConsoleDrainResult; + clear(): void; +}; + +/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */ +const PER_ARG_CAP = 2000; +/** Max stack frames preserved per record. */ +const MAX_STACK_FRAMES = 3; + +const compileTextMatcher = (pattern: string): ((text: string) => boolean) => { + if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) { + try { + const re = new RegExp(pattern.slice(1, -1)); + return (text) => re.test(text); + } catch { + // Fall through to substring match if the pattern is not a valid regex. + } + } + return (text) => text.includes(pattern); +}; + +const truncateArg = (s: string): string => + s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s; + +/** Best-effort stringification of one Runtime.RemoteObject. */ +const stringifyRemoteObject = ( + arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string }, +): string => { + if (arg.unserializableValue !== undefined) return arg.unserializableValue; + if (arg.value !== undefined) { + if (typeof arg.value === "string") return arg.value; + try { + return JSON.stringify(arg.value); + } catch { + return String(arg.value); + } + } + if (arg.description !== undefined) return arg.description; + return arg.type ?? ""; +}; + +/** Map CDP console-API method names to our normalized levels. */ +const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => { + switch (type) { + case "error": + case "assert": + return "error"; + case "warning": + return "warn"; + case "debug": + case "trace": + return "debug"; + case "info": + return "info"; + default: + return "log"; + } +}; + +/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */ +const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => { + switch (level) { + case "error": + return "error"; + case "warning": + return "warn"; + case "verbose": + return "debug"; + default: + return "info"; + } +}; + +const formatStackTrace = ( + st: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> } | undefined, +): string | undefined => { + const frames = st?.callFrames; + if (!frames || frames.length === 0) return undefined; + const top = frames.slice(0, MAX_STACK_FRAMES); + const rendered = top.map((f) => { + const fn = f.functionName !== undefined && f.functionName !== "" ? f.functionName : "<anonymous>"; + const loc = f.url !== undefined && f.url !== "" ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : "<unknown>"; + return ` at ${fn} (${loc})`; + }); + return rendered.join("\n"); +}; + +export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => { + // Insertion-ordered map keyed by seq. Map preserves insertion order in JS, + // so iteration gives us oldest-first eviction without a separate index. + const records = new Map<number, ConsoleRecord>(); + let nextSeq = 1; + let overflowed = false; + + const evictOldestIfFull = (): void => { + while (records.size >= capacity) { + const oldest = records.keys().next(); + if (oldest.done) return; + records.delete(oldest.value); + overflowed = true; + } + }; + + const insert = (rec: ConsoleRecord): void => { + evictOldestIfFull(); + records.set(rec.seq, rec); + }; + + return { + ingestConsoleApi(p) { + // CDP boundary cast: Runtime.consoleAPICalled + const params = p as + | { + type?: string; + args?: ReadonlyArray<{ + type?: string; + subtype?: string; + value?: unknown; + description?: string; + unserializableValue?: string; + }>; + stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; + } + | undefined; + if (!params) return; + const level = mapConsoleApiLevel(params.type); + const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a))); + const text = argTexts.join(" "); + const stack = level === "error" || level === "warn" ? formatStackTrace(params.stackTrace) : undefined; + const top = params.stackTrace?.callFrames?.[0]; + const rec: ConsoleRecord = { + seq: nextSeq++, + timestampMs: Date.now(), + level, + text, + source: "console-api", + ...(top?.url !== undefined && top.url !== "" ? { url: top.url } : {}), + ...(top?.lineNumber !== undefined ? { lineNumber: top.lineNumber + 1 } : {}), + ...(stack !== undefined ? { stackTrace: stack } : {}), + }; + insert(rec); + }, + + ingestLogEntry(p) { + // CDP boundary cast: Log.entryAdded + const params = p as + | { + entry?: { + level?: string; + text?: string; + url?: string; + lineNumber?: number; + stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; + }; + } + | undefined; + const entry = params?.entry; + if (!entry) return; + const level = mapLogEntryLevel(entry.level); + const text = truncateArg(entry.text ?? ""); + const stack = level === "error" || level === "warn" ? formatStackTrace(entry.stackTrace) : undefined; + const rec: ConsoleRecord = { + seq: nextSeq++, + timestampMs: Date.now(), + level, + text, + source: "log-entry", + ...(entry.url !== undefined && entry.url !== "" ? { url: entry.url } : {}), + ...(entry.lineNumber !== undefined ? { lineNumber: entry.lineNumber + 1 } : {}), + ...(stack !== undefined ? { stackTrace: stack } : {}), + }; + insert(rec); + }, + + drain(filter) { + const matchText = filter.textPattern !== undefined ? compileTextMatcher(filter.textPattern) : undefined; + const levels = filter.levels && filter.levels.length > 0 ? new Set(filter.levels) : undefined; + const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined; + const sinceSeq = filter.sinceSeq; + + const matched: ConsoleRecord[] = []; + for (const r of records.values()) { + if (sinceSeq !== undefined && r.seq <= sinceSeq) continue; + if (levels && !levels.has(r.level)) continue; + if (cutoff !== undefined && r.timestampMs < cutoff) continue; + if (matchText && !matchText(r.text)) continue; + matched.push({ ...r }); + } + + const total = matched.length; + const limit = Math.min(filter.limit ?? 50, 500); + const limited = matched.slice(-limit); + + const bufferOverflowed = overflowed; + overflowed = false; + + return { records: limited, total, bufferOverflowed }; + }, + + clear() { + records.clear(); + overflowed = false; + // nextSeq is intentionally NOT reset: the cursor remains monotonic across + // tab switches so a stale sinceSeq from a previous tab can't accidentally + // match new records. + }, + }; +}; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 89bdfdd..c839842 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -4,6 +4,7 @@ import type { DialogInfo } from "./types"; import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./transport"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; +import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer"; export type CdpSession = { attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>; @@ -15,6 +16,7 @@ export type CdpSession = { takeDialog(): DialogInfo | null; drainPageInfoInvalidations(): boolean; drainNetworkBuffer(filter: NetworkFilter): DrainResult; + drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult; }; export const createCdpSession = ( @@ -26,6 +28,7 @@ export const createCdpSession = ( let dialog: DialogInfo | null = null; let pageInfoDirty = false; const networkBuffer = createNetworkBuffer(); + const consoleBuffer = createConsoleBuffer(); let activeConsumer: Promise<void> = Promise.resolve(); @@ -55,6 +58,8 @@ export const createCdpSession = ( else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params); else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params); else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params); + else if (ev.method === "Runtime.consoleAPICalled") consoleBuffer.ingestConsoleApi(ev.params); + else if (ev.method === "Log.entryAdded") consoleBuffer.ingestLogEntry(ev.params); } }; @@ -82,7 +87,7 @@ export const createCdpSession = ( // Switching to Promise.all over a single WS pipelines the round-trips and // saves ~3× on tab-switch latency. Defer until session.ts has tests. const enableDomains = async (sid: string): Promise<void> => { - for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) { + for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) { await transport.request(`${d}.enable`, {}, { sessionId: sid }); } }; @@ -153,6 +158,7 @@ export const createCdpSession = ( // Buffer is page-scoped: on switch, the network history of the previous // tab is no longer relevant to what the agent is now looking at. networkBuffer.clear(); + consoleBuffer.clear(); await enableDomains(a.sessionId); return ok(undefined); }, @@ -181,5 +187,8 @@ export const createCdpSession = ( drainNetworkBuffer(filter) { return networkBuffer.drain(filter); }, + drainConsoleBuffer(filter) { + return consoleBuffer.drain(filter); + }, }; }; |
