From f265ff5aa15d8a0d01125f11ff5415c407aad4d9 Mon Sep 17 00:00:00 2001 From: Piyush Date: Wed, 25 Mar 2026 09:41:06 -0500 Subject: [PATCH] feat(cli): add contract evidence export to bap trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bap trace --export-evidence` to generate normalized skill-contract evidence from session traces. Maps all 26 BAP commands and 13 action/* subtypes. Direct action/* calls now appear in evidence.runtime.actions alongside agent/act composite actions. Also adds trace-recorder requestSummary capture and refreshes SKILL.md contract blocks. No changeset — ships with v1.0. buildContractEvidence is internal (not in package exports). Evidence output change is additive, not breaking. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + packages/cli/__tests__/commands/trace.test.ts | 247 ++++++ packages/cli/skills/bap-browser/SKILL.md | 85 ++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/trace.ts | 738 ++++++++++++------ .../src/recording/trace-recorder.ts | 87 +++ packages/server-playwright/src/server.ts | 2 + skills/bap-browser/SKILL.md | 86 ++ 8 files changed, 1023 insertions(+), 224 deletions(-) create mode 100644 packages/cli/__tests__/commands/trace.test.ts diff --git a/.gitignore b/.gitignore index 30258bd..9fc8550 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ ROADMAP.md # Demo recording intermediates assets/demos/*-events.json assets/demos/*-raw.webm +.dev-session/ diff --git a/packages/cli/__tests__/commands/trace.test.ts b/packages/cli/__tests__/commands/trace.test.ts new file mode 100644 index 0000000..745272f --- /dev/null +++ b/packages/cli/__tests__/commands/trace.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const existsSync = vi.fn(); +const readFileSync = vi.fn(); +const readdirSync = vi.fn(); +const statSync = vi.fn(); +const mkdirSync = vi.fn(); +const writeFileSync = vi.fn(); +const register = vi.fn(); + +vi.mock('node:fs', () => ({ + default: { + existsSync, + readFileSync, + readdirSync, + statSync, + mkdirSync, + writeFileSync, + }, +})); + +vi.mock('node:os', () => ({ + default: { + homedir: () => '/tmp/test-home', + }, +})); + +vi.mock('../../src/commands/registry.js', () => ({ + register, +})); + +const { buildContractEvidence, traceCommand } = await import('../../src/commands/trace.js'); + +describe('trace contract evidence export', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('normalizes BAP trace entries into contract evidence', () => { + const evidence = buildContractEvidence([ + { + ts: '2026-03-24T18:00:00.000Z', + sessionId: 'checkout-demo', + clientId: 'client-1', + method: 'page/navigate', + duration: 42, + status: 'ok', + requestSummary: { + url: 'https://example.com/login', + observe: { + responseTier: 'interactive', + stableRefs: true, + }, + }, + resultSummary: { + url: 'https://example.com/login', + status: 200, + }, + }, + { + ts: '2026-03-24T18:00:05.000Z', + sessionId: 'checkout-demo', + clientId: 'client-1', + method: 'agent/act', + duration: 85, + status: 'ok', + requestSummary: { + actions: ['action/fill', 'action/click'], + postObserve: { + incremental: true, + includeScreenshot: true, + stableRefs: true, + }, + }, + resultSummary: { + completed: 2, + total: 2, + }, + }, + { + ts: '2026-03-24T18:00:06.000Z', + sessionId: 'checkout-demo', + clientId: 'client-1', + method: 'agent/extract', + duration: 33, + status: 'ok', + requestSummary: { + mode: 'list', + }, + resultSummary: { + keys: ['data', 'sourceRefs'], + }, + }, + ]); + + expect(evidence).toEqual({ + adapter: 'bap', + version: 1, + runtime: { + tools: ['act', 'extract', 'navigate', 'observe'], + actions: ['click', 'fill'], + domains: ['https://example.com'], + artifacts: ['json-extraction', 'screenshot', 'trace-jsonl'], + approvalsObserved: [], + }, + provenance: { + formats: ['bap-trace-jsonl'], + replaySupported: true, + determinism: 'best-effort', + validator: 'bap trace --replay', + }, + grounding: { + observationModels: [ + 'incremental-changes', + 'interactive-elements', + 'screenshot-observation', + ], + identityMechanisms: ['selector-fallback', 'semantic-selector', 'stable-ref'], + stableRefs: true, + abstentionSupported: false, + }, + }); + }); + + it('captures direct action/* method calls as tools and actions', () => { + const evidence = buildContractEvidence([ + { + ts: '2026-03-24T18:00:00.000Z', + sessionId: 'direct-actions', + clientId: 'client-1', + method: 'action/click', + duration: 15, + status: 'ok', + requestSummary: { selector: 'e5' }, + }, + { + ts: '2026-03-24T18:00:01.000Z', + sessionId: 'direct-actions', + clientId: 'client-1', + method: 'action/fill', + duration: 20, + status: 'ok', + requestSummary: { selector: 'e8', value: 'test@example.com' }, + }, + { + ts: '2026-03-24T18:00:02.000Z', + sessionId: 'direct-actions', + clientId: 'client-1', + method: 'action/hover', + duration: 10, + status: 'ok', + requestSummary: { selector: 'e12' }, + }, + ]); + + // Direct action/* calls should appear in both tools AND actions + expect(evidence.runtime?.tools).toContain('click'); + expect(evidence.runtime?.tools).toContain('fill'); + expect(evidence.runtime?.tools).toContain('hover'); + expect(evidence.runtime?.actions).toContain('click'); + expect(evidence.runtime?.actions).toContain('fill'); + expect(evidence.runtime?.actions).toContain('hover'); + }); + + it('exports normalized evidence from the trace command', async () => { + existsSync.mockReturnValue(true); + readdirSync.mockReturnValue(['checkout-demo-123.jsonl']); + statSync.mockReturnValue({ + size: 512, + mtime: new Date('2026-03-24T18:01:00.000Z'), + }); + readFileSync.mockReturnValue( + [ + JSON.stringify({ + ts: '2026-03-24T18:00:00.000Z', + sessionId: 'checkout-demo', + clientId: 'client-1', + method: 'page/navigate', + duration: 42, + status: 'ok', + requestSummary: { + url: 'https://example.com/login', + observe: { responseTier: 'interactive', stableRefs: true }, + }, + resultSummary: { url: 'https://example.com/login', status: 200 }, + }), + JSON.stringify({ + ts: '2026-03-24T18:00:05.000Z', + sessionId: 'checkout-demo', + clientId: 'client-1', + method: 'agent/act', + duration: 85, + status: 'ok', + requestSummary: { + actions: ['action/fill', 'action/click'], + postObserve: { + incremental: true, + includeScreenshot: true, + stableRefs: true, + }, + }, + resultSummary: { completed: 2, total: 2 }, + }), + ].join('\n'), + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await traceCommand(['--export-evidence=.bap/trace-evidence.json'], {} as never, {} as never); + + expect(mkdirSync).toHaveBeenCalled(); + expect(writeFileSync).toHaveBeenCalledTimes(1); + + const [, writtenJson] = writeFileSync.mock.calls[0] as [string, string]; + expect(JSON.parse(writtenJson)).toEqual({ + adapter: 'bap', + version: 1, + runtime: { + tools: ['act', 'navigate', 'observe'], + actions: ['click', 'fill'], + domains: ['https://example.com'], + artifacts: ['screenshot', 'trace-jsonl'], + approvalsObserved: [], + }, + provenance: { + formats: ['bap-trace-jsonl'], + replaySupported: true, + determinism: 'best-effort', + validator: 'bap trace --replay', + }, + grounding: { + observationModels: [ + 'incremental-changes', + 'interactive-elements', + 'screenshot-observation', + ], + identityMechanisms: ['selector-fallback', 'semantic-selector', 'stable-ref'], + stableRefs: true, + abstentionSupported: false, + }, + }); + + expect(logSpy).toHaveBeenCalledWith('Exported contract evidence to .bap/trace-evidence.json'); + expect(errorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/skills/bap-browser/SKILL.md b/packages/cli/skills/bap-browser/SKILL.md index fca1a28..e056ccc 100644 --- a/packages/cli/skills/bap-browser/SKILL.md +++ b/packages/cli/skills/bap-browser/SKILL.md @@ -2,6 +2,89 @@ name: bap-browser description: "Browser automation CLI with composite actions, semantic selectors, and self-healing selectors. Use when the user needs to visit websites, fill forms, extract data, take screenshots, stream browser events, or automate multi-step browser workflows like login, checkout, or search." license: Apache-2.0 +contract: + kind: browser-agent + version: 1 + runtime: + interfaces: + - cli + tools: + - navigate + - go_back + - go_forward + - reload + - observe + - screenshot + - aria_snapshot + - content + - act + - extract + - pages + - activate_page + - close_page + actionClasses: + - navigate + - observe + - click + - fill + - type + - press + - hover + - scroll + - select + - extract + domainPolicy: + mode: report + approval: + policy: manual + requiredFor: + - checkout + - purchase + - delete + - upload + - submit + artifacts: + outputs: + - trace-jsonl + - trace-replay-html + - json-extraction + - screenshot + sensitivity: moderate + retention: session + redaction: + - cookies + - auth-tokens + - passwords + provenance: + formats: + - bap-trace-jsonl + replay: + supported: true + determinism: best-effort + validator: bap trace --replay + grounding: + observation: + models: + - interactive-elements + - incremental-changes + - screenshot-observation + identity: + mechanisms: + - stable-ref + - semantic-selector + - selector-fallback + stableRefs: true + abstention: + supported: false + reasons: + - delegated-to-caller + extensions: + cliAliases: + navigate: goto + go_back: back + go_forward: forward + pages: tabs + activate_page: tab-select --- # BAP Browser CLI @@ -168,6 +251,7 @@ bap trace --all # Show all traces across sessions bap trace --session= # Traces for a specific session bap trace --replay # Generate self-contained HTML timeline viewer bap trace --export # Export traces as JSON +bap trace --export-evidence=evidence.json # Export normalized contract evidence bap trace --limit=20 # Limit number of trace entries shown ``` @@ -211,3 +295,4 @@ bap recipe wait-for [--timeout=ms] 4. Use `bap act` for multi-step flows instead of individual commands — fewer calls, fewer tokens 5. Use `--diff` for incremental observation after small DOM changes 6. Check `bap trace` when debugging failures — it records every request with timing +7. Use `bap trace --export-evidence=...` when you need normalized contract audit evidence for skill validation diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2360ff8..a10c13c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -103,6 +103,7 @@ ${pc.cyan("TRACING")} bap trace --session= Show trace for a specific session bap trace --replay Generate HTML timeline viewer bap trace --export= Export trace as JSON + bap trace --export-evidence= Export normalized contract evidence bap trace --limit= Show last N entries (default: 10) ${pc.cyan("DEBUGGING")} diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts index 1dd3666..3ad1584 100644 --- a/packages/cli/src/commands/trace.ts +++ b/packages/cli/src/commands/trace.ts @@ -4,131 +4,412 @@ * bap trace --session= — Show trace for a specific session * bap trace --sessions — List all trace sessions * bap trace --export= — Export trace to JSON file + * bap trace --export-evidence= — Export normalized skill-contract evidence * bap trace --replay — Generate self-contained HTML timeline viewer */ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import type { BAPClient } from "@browseragentprotocol/client"; -import type { GlobalFlags } from "../config/state.js"; -import { register } from "./registry.js"; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type { BAPClient } from '@browseragentprotocol/client'; +import type { GlobalFlags } from '../config/state.js'; +import { register } from './registry.js'; interface TraceEntry { - ts: string; - sessionId?: string; - clientId: string; - method: string; - duration: number; - status: "ok" | "error"; - error?: string; - resultSummary?: Record; + ts: string; + sessionId?: string; + clientId: string; + method: string; + duration: number; + status: 'ok' | 'error'; + error?: string; + requestSummary?: Record; + resultSummary?: Record; } -const TRACE_DIR = path.join(os.homedir(), ".bap", "traces"); +interface ContractRuntimeEvidence { + tools?: string[]; + actions?: string[]; + domains?: string[]; + artifacts?: string[]; + approvalsObserved?: string[]; +} + +interface ContractProvenanceEvidence { + formats?: string[]; + replaySupported?: boolean; + determinism?: 'none' | 'best-effort' | 'strict'; + validator?: string; +} + +interface ContractGroundingEvidence { + observationModels?: string[]; + identityMechanisms?: string[]; + stableRefs?: boolean; + abstentionSupported?: boolean; +} + +interface ContractEvidence { + adapter: 'bap'; + version: 1; + runtime?: ContractRuntimeEvidence; + provenance?: ContractProvenanceEvidence; + grounding?: ContractGroundingEvidence; +} + +const TRACE_DIR = path.join(os.homedir(), '.bap', 'traces'); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} function readTraceFile(filepath: string): TraceEntry[] { - try { - const content = fs.readFileSync(filepath, "utf-8"); - return content - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as TraceEntry; - } catch { - return null; - } - }) - .filter((e): e is TraceEntry => e !== null); - } catch { - return []; - } + try { + const content = fs.readFileSync(filepath, 'utf-8'); + return content + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as TraceEntry; + } catch { + return null; + } + }) + .filter((e): e is TraceEntry => e !== null); + } catch { + return []; + } } function countLines(filepath: string): number { - try { - const content = fs.readFileSync(filepath, "utf-8"); - return content.trim().split("\n").filter(Boolean).length; - } catch { - return 0; - } + try { + const content = fs.readFileSync(filepath, 'utf-8'); + return content.trim().split('\n').filter(Boolean).length; + } catch { + return 0; + } +} + +function normalizeToolName(method: string): string | null { + switch (method) { + case 'page/navigate': + return 'navigate'; + case 'page/reload': + return 'reload'; + case 'page/goBack': + return 'go_back'; + case 'page/goForward': + return 'go_forward'; + case 'page/close': + return 'close_page'; + case 'page/list': + return 'pages'; + case 'page/activate': + return 'activate_page'; + case 'observe/screenshot': + return 'screenshot'; + case 'observe/accessibility': + return 'accessibility'; + case 'observe/content': + return 'content'; + case 'observe/element': + return 'element'; + case 'observe/ariaSnapshot': + return 'aria_snapshot'; + case 'observe/dom': + return 'dom'; + case 'observe/pdf': + return 'pdf'; + case 'agent/act': + return 'act'; + case 'agent/observe': + return 'observe'; + case 'agent/extract': + return 'extract'; + case 'discovery/discover': + return 'discover_tools'; + case 'action/click': + return 'click'; + case 'action/dblclick': + return 'dblclick'; + case 'action/fill': + return 'fill'; + case 'action/type': + return 'type'; + case 'action/press': + return 'press'; + case 'action/hover': + return 'hover'; + case 'action/scroll': + return 'scroll'; + case 'action/select': + return 'select'; + case 'action/check': + return 'check'; + case 'action/uncheck': + return 'uncheck'; + case 'action/clear': + return 'clear'; + case 'action/upload': + return 'upload'; + case 'action/drag': + return 'drag'; + default: + return null; + } +} + +function normalizeActionName(action: string): string { + switch (action) { + case 'page/navigate': + return 'navigate'; + case 'page/reload': + return 'reload'; + case 'page/goBack': + return 'go_back'; + case 'page/goForward': + return 'go_forward'; + default: + return action.replace(/^action\//, ''); + } +} + +function extractOrigin(url: unknown): string | null { + if (typeof url !== 'string' || url.length === 0) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function collectObserveSummaries(entry: TraceEntry): Array> { + const summaries: Array> = []; + + if (!isRecord(entry.requestSummary)) { + return summaries; + } + + if (entry.method === 'agent/observe') { + summaries.push(entry.requestSummary); + } + + for (const key of ['observe', 'preObserve', 'postObserve']) { + const value = entry.requestSummary[key]; + if (isRecord(value)) { + summaries.push(value); + } + } + + return summaries; +} + +function resolveOutputPath(target: string): string | null { + const resolved = path.resolve(target); + const cwd = process.cwd(); + const bapDir = path.join(os.homedir(), '.bap'); + + if ( + !resolved.startsWith(cwd + path.sep) && + !resolved.startsWith(bapDir + path.sep) && + resolved !== cwd && + resolved !== bapDir + ) { + console.error('Export path must be under current directory or ~/.bap/'); + return null; + } + + return resolved; +} + +export function buildContractEvidence(entries: TraceEntry[]): ContractEvidence { + const tools = new Set(); + const actions = new Set(); + const domains = new Set(); + const artifacts = new Set(['trace-jsonl']); + const approvalsObserved = new Set(); + const observationModels = new Set(); + const identityMechanisms = new Set(); + + let sawObservation = false; + let stableRefsObserved: boolean | undefined; + + for (const entry of entries) { + const tool = normalizeToolName(entry.method); + if (tool) { + tools.add(tool); + } + + // Extract actions from direct action/* method calls + if (entry.method.startsWith('action/')) { + actions.add(normalizeActionName(entry.method)); + } + + if (entry.method === 'approval/respond') { + approvalsObserved.add('manual'); + } + + if (isRecord(entry.requestSummary)) { + const url = extractOrigin(entry.requestSummary.url); + if (url) domains.add(url); + + if (Array.isArray(entry.requestSummary.actions)) { + for (const action of entry.requestSummary.actions) { + if (typeof action === 'string') { + actions.add(normalizeActionName(action)); + } + } + } + } + + const resultUrl = extractOrigin(entry.resultSummary?.url); + if (resultUrl) { + domains.add(resultUrl); + } + + const observeSummaries = collectObserveSummaries(entry); + if (observeSummaries.length > 0 || entry.method === 'agent/observe') { + sawObservation = true; + tools.add('observe'); + } + + for (const observeSummary of observeSummaries) { + observationModels.add('interactive-elements'); + if (observeSummary.incremental === true) { + observationModels.add('incremental-changes'); + } + if (observeSummary.includeScreenshot === true || observeSummary.annotateScreenshot === true) { + observationModels.add('screenshot-observation'); + artifacts.add('screenshot'); + } + + if (typeof observeSummary.stableRefs === 'boolean') { + stableRefsObserved = stableRefsObserved ?? false; + stableRefsObserved = stableRefsObserved || observeSummary.stableRefs; + } + } + + if (entry.method === 'observe/screenshot' || entry.resultSummary?.hasScreenshot === true) { + observationModels.add('screenshot-observation'); + artifacts.add('screenshot'); + } + + if (entry.method === 'agent/extract') { + artifacts.add('json-extraction'); + } + + if (entry.method === 'observe/pdf') { + artifacts.add('pdf'); + } + } + + if (sawObservation) { + identityMechanisms.add('stable-ref'); + identityMechanisms.add('semantic-selector'); + identityMechanisms.add('selector-fallback'); + stableRefsObserved = stableRefsObserved ?? true; + } + + return { + adapter: 'bap', + version: 1, + runtime: { + tools: [...tools].sort(), + actions: [...actions].sort(), + domains: [...domains].sort(), + artifacts: [...artifacts].sort(), + approvalsObserved: [...approvalsObserved].sort(), + }, + provenance: { + formats: ['bap-trace-jsonl'], + replaySupported: true, + determinism: 'best-effort', + validator: 'bap trace --replay', + }, + grounding: { + observationModels: [...observationModels].sort(), + identityMechanisms: [...identityMechanisms].sort(), + stableRefs: stableRefsObserved, + abstentionSupported: false, + }, + }; } function listTraceSessions(): Array<{ - sessionId: string; - file: string; - entries: number; - size: number; - modified: Date; + sessionId: string; + file: string; + entries: number; + size: number; + modified: Date; }> { - if (!fs.existsSync(TRACE_DIR)) return []; - - return fs - .readdirSync(TRACE_DIR) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => { - const stat = fs.statSync(path.join(TRACE_DIR, f)); - const sessionId = f.replace(/-\d+\.jsonl$/, ""); - // Count lines without JSON parsing — O(n) string scan vs O(n*m) parse - const entries = countLines(path.join(TRACE_DIR, f)); - return { sessionId, file: f, entries, size: stat.size, modified: stat.mtime }; - }) - .sort((a, b) => b.modified.getTime() - a.modified.getTime()); + if (!fs.existsSync(TRACE_DIR)) return []; + + return fs + .readdirSync(TRACE_DIR) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => { + const stat = fs.statSync(path.join(TRACE_DIR, f)); + const sessionId = f.replace(/-\d+\.jsonl$/, ''); + // Count lines without JSON parsing — O(n) string scan vs O(n*m) parse + const entries = countLines(path.join(TRACE_DIR, f)); + return { sessionId, file: f, entries, size: stat.size, modified: stat.mtime }; + }) + .sort((a, b) => b.modified.getTime() - a.modified.getTime()); } function formatDuration(ms: number): string { - if (ms < 1) return "<1ms"; - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(1)}s`; + if (ms < 1) return '<1ms'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; } function formatEntry(entry: TraceEntry, index: number): string { - const status = entry.status === "ok" ? "\u2713" : "\u2717"; - const time = new Date(entry.ts).toLocaleTimeString(); - const dur = formatDuration(entry.duration); - let summary = ""; - - if (entry.error) { - summary = ` error="${entry.error}"`; - } else if (entry.resultSummary) { - const rs = entry.resultSummary; - if (rs.url) summary += ` url=${rs.url}`; - if (rs.elementCount !== undefined) summary += ` elements=${rs.elementCount}`; - if (rs.status !== undefined) summary += ` status=${rs.status}`; - if (rs.sizeKB !== undefined) summary += ` ${rs.sizeKB}KB`; - if (rs.completed !== undefined) summary += ` ${rs.completed}/${rs.total}`; - if (rs.count !== undefined) summary += ` count=${rs.count}`; - } - - return ` ${String(index + 1).padStart(3)} ${status} ${time} ${entry.method.padEnd(25)} ${dur.padStart(7)}${summary}`; + const status = entry.status === 'ok' ? '\u2713' : '\u2717'; + const time = new Date(entry.ts).toLocaleTimeString(); + const dur = formatDuration(entry.duration); + let summary = ''; + + if (entry.error) { + summary = ` error="${entry.error}"`; + } else if (entry.resultSummary) { + const rs = entry.resultSummary; + if (rs.url) summary += ` url=${rs.url}`; + if (rs.elementCount !== undefined) summary += ` elements=${rs.elementCount}`; + if (rs.status !== undefined) summary += ` status=${rs.status}`; + if (rs.sizeKB !== undefined) summary += ` ${rs.sizeKB}KB`; + if (rs.completed !== undefined) summary += ` ${rs.completed}/${rs.total}`; + if (rs.count !== undefined) summary += ` count=${rs.count}`; + } + + return ` ${String(index + 1).padStart(3)} ${status} ${time} ${entry.method.padEnd(25)} ${dur.padStart(7)}${summary}`; } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } function generateHtmlReplay(entries: TraceEntry[], sessionId: string): string { - const rows = entries - .map((e, i) => { - const cls = e.status === "error" ? "error" : ""; - const summary = e.error - ? `${escapeHtml(e.error)}` - : escapeHtml(JSON.stringify(e.resultSummary ?? {})); - return `${i + 1}${new Date(e.ts).toLocaleTimeString()}${escapeHtml(e.method)}${formatDuration(e.duration)}${e.status}${summary}`; - }) - .join("\n"); - - const totalDuration = entries.reduce((s, e) => s + e.duration, 0); - const errorCount = entries.filter((e) => e.status === "error").length; - - return ` + const rows = entries + .map((e, i) => { + const cls = e.status === 'error' ? 'error' : ''; + const summary = e.error + ? `${escapeHtml(e.error)}` + : escapeHtml(JSON.stringify(e.resultSummary ?? {})); + return `${i + 1}${new Date(e.ts).toLocaleTimeString()}${escapeHtml(e.method)}${formatDuration(e.duration)}${e.status}${summary}`; + }) + .join('\n'); + + const totalDuration = entries.reduce((s, e) => s + e.duration, 0); + const errorCount = entries.filter((e) => e.status === 'error').length; + + return ` BAP Trace: ${escapeHtml(sessionId)}