diff --git a/bun.lock b/bun.lock
index e0f417e6f6..6507410dbe 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,7 +12,6 @@
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
- "@types/better-sqlite3": "7.6.13",
"@types/pg": "8.18.0",
"@typescript/native-preview": "catalog:",
"husky": "9.1.7",
@@ -42,7 +41,6 @@
"optionalDependencies": {
"@databricks/sql": "^1.0.0",
"@google-cloud/bigquery": "^8.0.0",
- "better-sqlite3": "^11.0.0",
"duckdb": "^1.0.0",
"mssql": "^11.0.0",
"mysql2": "^3.0.0",
@@ -171,6 +169,7 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
+ "playwright-core": "1.58.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -1286,7 +1285,7 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
- "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
@@ -2062,6 +2061,8 @@
"planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="],
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
+
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -3126,10 +3127,10 @@
"table-layout/typical": ["typical@7.3.0", "", {}, "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw=="],
- "tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
-
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
+ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
"tar-stream/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
diff --git a/package.json b/package.json
index b532d05b7f..d6fc53e1c5 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"@types/pg": "8.18.0",
"@typescript/native-preview": "catalog:",
"husky": "9.1.7",
+ "playwright-core": "1.58.2",
"prettier": "3.6.2",
"semver": "^7.6.0",
"turbo": "2.8.13"
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 96c8cb1924..f4d5516081 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -47,6 +47,7 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
+ "playwright-core": "1.58.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
diff --git a/packages/opencode/src/altimate/observability/viewer.ts b/packages/opencode/src/altimate/observability/viewer.ts
index 6eec3d0d63..c9c4df337a 100644
--- a/packages/opencode/src/altimate/observability/viewer.ts
+++ b/packages/opencode/src/altimate/observability/viewer.ts
@@ -80,7 +80,13 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Ar
.wf-icon.generation { background: rgba(77,142,255,0.15); color: var(--secondary); }
.wf-icon.tool { background: rgba(34,211,238,0.12); color: var(--cyan); }
.wf-icon.error { background: rgba(248,113,113,0.15); color: var(--red); }
-.wf-name { font-size: 13px; font-weight: 500; width: 200px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.wf-info { width: 300px; flex-shrink: 0; overflow: hidden; min-width: 0; }
+.wf-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.wf-preview { font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 1px; }
+.wf-preview .pv-tag { display: inline-block; font-size: 10px; font-weight: 600; padding: 0 4px; border-radius: 3px; margin-right: 4px; vertical-align: baseline; }
+.wf-preview .pv-tag.model { background: rgba(77,142,255,0.12); color: var(--secondary); }
+.wf-preview .pv-tag.tok { background: rgba(74,222,128,0.12); color: var(--green); }
+.wf-preview .pv-tag.err { background: rgba(248,113,113,0.12); color: var(--red); }
.wf-bar-c { flex: 1; height: 18px; position: relative; overflow: hidden; }
.wf-bar { position: absolute; height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.85; display: flex; align-items: center; padding-left: 4px; }
.wf-bar.generation { background: var(--secondary); }
@@ -102,6 +108,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Ar
.tree-type.session { background: rgba(77,142,255,0.1); color: var(--primary); }
.tree-title { font-size: 13px; font-weight: 500; }
.tree-meta { font-size: 12px; color: var(--dim); display: flex; gap: 12px; margin-top: 2px; }
+.tree-preview { font-size: 11px; color: var(--dim); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 600px; }
+.tree-preview .pv-tag { display: inline-block; font-size: 10px; font-weight: 600; padding: 0 4px; border-radius: 3px; margin-right: 4px; vertical-align: baseline; }
+.tree-preview .pv-tag.model { background: rgba(77,142,255,0.12); color: var(--secondary); }
+.tree-preview .pv-tag.tok { background: rgba(74,222,128,0.12); color: var(--green); }
+.tree-preview .pv-tag.err { background: rgba(248,113,113,0.12); color: var(--red); }
.tree-detail { margin-top: 8px; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; display: none; }
.tree-detail.open { display: block; }
@@ -121,8 +132,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Ar
.chat-tool pre { background: var(--bg); padding: 6px; border-radius: 4px; margin-top: 4px; font-size: 11px; overflow-x: auto; max-height: 150px; overflow-y: auto; }
/* ---- Log View ---- */
-.log-entry { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
+.log-entry { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; font-family: 'JetBrains Mono', 'Fira Code', monospace; cursor: pointer; }
.log-entry:hover { background: var(--s1); }
+.log-entry.sel { background: var(--s2); }
.log-ts { color: var(--dim); font-size: 11px; margin-right: 8px; }
.log-kind { font-size: 10px; font-weight: 600; text-transform: uppercase; padding: 1px 4px; border-radius: 3px; margin-right: 8px; }
.log-kind.generation { background: rgba(77,142,255,0.15); color: var(--secondary); }
@@ -221,10 +233,12 @@ document.getElementById('cards').innerHTML = cardsData.filter(function(c) { retu
// --- Tab switching ---
document.getElementById('tabs').addEventListener('click', function(ev) {
- var view = ev.target.dataset.view; if (!view) return;
- document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
- document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
- ev.target.classList.add('active');
+ var tab = ev.target.closest ? ev.target.closest('.tab') : ev.target;
+ if (!tab || !tab.dataset || !tab.dataset.view) return;
+ var view = tab.dataset.view;
+ document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
+ document.querySelectorAll('.view').forEach(function(el) { el.classList.remove('active'); });
+ tab.classList.add('active');
document.getElementById('v-' + view).classList.add('active');
document.getElementById('detail').innerHTML = '';
});
@@ -235,6 +249,53 @@ var tEnd = spans.length ? Math.max.apply(null, spans.map(function(s) { return s.
var tTotal = tEnd - tStart || 1;
var icons = { session: '\\u25A0', generation: '\\u2B50', tool: '\\u2692', text: '\\u270E' };
+// --- Inline preview helper ---
+// Returns a short HTML string summarizing a span without expanding it.
+function getPreview(span) {
+ var parts = [];
+ // Error message always shown
+ if (span.status === 'error' && span.statusMessage) {
+ return '\\u2718' + e((span.statusMessage || '').slice(0, 120));
+ }
+ if (span.kind === 'tool') {
+ // Extract a useful summary from tool input
+ var inp = span.input;
+ if (inp) {
+ if (typeof inp === 'string') {
+ parts.push(e(inp.slice(0, 120)));
+ } else if (typeof inp === 'object') {
+ // Common tool input patterns
+ var o = inp;
+ if (o.command) parts.push(e(String(o.command).slice(0, 120)));
+ else if (o.file_path) parts.push(e(String(o.file_path)));
+ else if (o.pattern && o.path) parts.push(e(o.pattern + ' in ' + o.path));
+ else if (o.pattern) parts.push(e(String(o.pattern)));
+ else if (o.query) parts.push(e(String(o.query).slice(0, 120)));
+ else if (o.url) parts.push(e(String(o.url).slice(0, 120)));
+ else if (o.prompt) parts.push(e(String(o.prompt).slice(0, 120)));
+ else if (o.description) parts.push(e(String(o.description).slice(0, 120)));
+ else {
+ var s = JSON.stringify(o);
+ if (s.length > 120) s = s.slice(0, 120) + '...';
+ parts.push(e(s));
+ }
+ }
+ }
+ if (span.status === 'error') parts.unshift('\\u2718');
+ } else if (span.kind === 'generation') {
+ if (span.model && span.model.modelId) parts.push('' + e(span.model.modelId) + '');
+ if (span.tokens && span.tokens.total) parts.push('' + Number(span.tokens.total).toLocaleString() + ' tok');
+ if (span.finishReason && span.finishReason !== 'stop') parts.push(e(span.finishReason));
+ if (span.cost) parts.push(fc(span.cost));
+ } else if (span.kind === 'text') {
+ if (span.input) {
+ var txt = typeof span.input === 'string' ? span.input : JSON.stringify(span.input);
+ parts.push(e(txt.slice(0, 120)));
+ }
+ }
+ return parts.join(' ');
+}
+
// --- Detail panel ---
function showDetail(span) {
var dur = (span.endTime || Date.now()) - (span.startTime || 0);
@@ -304,7 +365,7 @@ function showDetail(span) {
// ===================== WATERFALL VIEW =====================
(function() {
var el = document.getElementById('v-waterfall');
- nonSession.forEach(function(span) {
+ nonSession.forEach(function(span, idx) {
var st = (span.startTime||0) - tStart;
var dur = (span.endTime || Date.now()) - (span.startTime||0);
var left = (st / tTotal * 100).toFixed(2);
@@ -312,18 +373,24 @@ function showDetail(span) {
var cls = span.status === 'error' ? 'error' : e(span.kind);
var row = document.createElement('div');
row.className = 'wf-row';
+ row.setAttribute('data-idx', String(idx));
var iconCls = span.status === 'error' ? 'error' : e(span.kind);
+ var pv = getPreview(span);
row.innerHTML = '
' + (icons[span.kind]||'\\u2022') + '
' +
- '' + e(span.name) + '
' +
+ '' + e(span.name) + '
' + (pv ? '
' + pv + '
' : '') + '
' +
'' +
'' + fd(dur) + '
';
- row.onclick = function() {
- document.querySelectorAll('.wf-row').forEach(function(r){r.classList.remove('sel');});
- row.classList.add('sel');
- showDetail(span);
- };
el.appendChild(row);
});
+ el.addEventListener('click', function(ev) {
+ var row = ev.target.closest ? ev.target.closest('.wf-row') : ev.target;
+ if (!row || !row.dataset || row.dataset.idx == null) return;
+ var span = nonSession[Number(row.dataset.idx)];
+ if (!span) return;
+ document.querySelectorAll('.wf-row').forEach(function(r){r.classList.remove('sel');});
+ row.classList.add('sel');
+ showDetail(span);
+ });
})();
// ===================== TREE VIEW =====================
@@ -335,17 +402,20 @@ function showDetail(span) {
if (!children.length) return '';
var html = '';
children.forEach(function(span) {
+ var idx = spans.indexOf(span);
var dur = (span.endTime||Date.now()) - (span.startTime||0);
var meta = [];
meta.push(fd(dur));
if (span.tokens) meta.push(Number(span.tokens.total||0) + ' tok');
if (span.cost) meta.push(fc(span.cost));
if (span.status === 'error') meta.push('error');
- html += '';
+ html += '
';
html += '
';
html += '' + e(span.kind) + '';
html += '' + e(span.name) + '';
html += '
';
+ var treePv = getPreview(span);
+ if (treePv) html += '
' + treePv + '
';
html += '
' + meta.join(' · ') + '
';
html += '
';
html += buildTree(span.spanId);
@@ -356,12 +426,11 @@ function showDetail(span) {
var rootId = sessionSpan ? sessionSpan.spanId : null;
el.innerHTML = buildTree(rootId) || '
No spans recorded yet.
';
el.addEventListener('click', function(ev) {
- var item = ev.target.closest('.tree-item');
- if (!item) return;
- var sid = item.dataset.sid;
- var span = spans.find(function(s){return s.spanId===sid;});
+ var item = ev.target.closest ? ev.target.closest('.tree-item') : null;
+ if (!item || item.dataset.idx == null) return;
+ var span = spans[Number(item.dataset.idx)];
if (!span) return;
- document.querySelectorAll('.tree-item').forEach(function(i){i.classList.remove('sel');});
+ document.querySelectorAll('.tree-item').forEach(function(el){el.classList.remove('sel');});
item.classList.add('sel');
showDetail(span);
});
@@ -419,17 +488,24 @@ function showDetail(span) {
var sorted = spans.slice().sort(function(a,b){return (a.startTime||0)-(b.startTime||0);});
sorted.forEach(function(span) {
if (span.kind === 'session') return;
+ var idx = spans.indexOf(span);
var ts = span.startTime ? new Date(span.startTime).toISOString().slice(11,23) : '';
var kindCls = span.status === 'error' ? 'error' : e(span.kind);
- html += '
';
+ html += '
';
html += '
' + ts + '';
var logIcon = span.kind === 'generation' ? '\\u2B50' : span.kind === 'tool' ? '\\u2692' : '\\u25A0';
html += '
' + logIcon + ' ' + e(span.kind||'') + '';
html += '
' + e(span.name) + '';
+ if (span.kind === 'generation' && span.model && span.model.modelId) html += '
' + e(span.model.modelId) + '';
if (span.tokens) html += '
' + Number(span.tokens.total||0) + ' tok';
if (span.cost) html += '
' + fc(span.cost) + '';
if (span.tool && span.tool.durationMs != null) html += '
' + fd(span.tool.durationMs) + '';
if (span.status === 'error') html += '
\\u2718 ' + e((span.statusMessage||'').slice(0,100)) + '';
+ // Show inline preview for tools (input is more useful than output)
+ if (span.kind === 'tool' && span.input) {
+ var logPv = getPreview(span);
+ if (logPv) html += '
' + logPv + '
';
+ }
// Show input/output preview
if (span.output) {
var out = typeof span.output === 'string' ? span.output : JSON.stringify(span.output);
@@ -440,6 +516,15 @@ function showDetail(span) {
});
if (!html) html = '
No log entries yet.
';
el.innerHTML = html;
+ el.addEventListener('click', function(ev) {
+ var entry = ev.target.closest ? ev.target.closest('.log-entry') : null;
+ if (!entry || entry.dataset.idx == null) return;
+ var span = spans[Number(entry.dataset.idx)];
+ if (!span) return;
+ document.querySelectorAll('.log-entry').forEach(function(el){el.classList.remove('sel');});
+ entry.classList.add('sel');
+ showDetail(span);
+ });
})();
// ===================== LIVE POLLING =====================
diff --git a/packages/opencode/test/altimate/tracing-viewer-e2e.test.ts b/packages/opencode/test/altimate/tracing-viewer-e2e.test.ts
new file mode 100644
index 0000000000..6e9170ecaa
--- /dev/null
+++ b/packages/opencode/test/altimate/tracing-viewer-e2e.test.ts
@@ -0,0 +1,1108 @@
+/**
+ * End-to-end tests for the trace viewer HTML renderer.
+ *
+ * Uses Playwright with a real Chromium browser to test the viewer from a
+ * user's perspective: clicking tabs, selecting spans, verifying detail panels,
+ * and exercising adversarial edge cases (XSS, empty data, huge payloads, etc.).
+ */
+import { describe, expect, test, beforeAll, afterAll } from "bun:test"
+import { chromium, type Browser, type Page } from "playwright-core"
+import { renderTraceViewer } from "../../src/altimate/observability/viewer"
+import type { TraceFile } from "../../src/altimate/observability/tracing"
+import fs from "fs"
+import path from "path"
+
+// ---------------------------------------------------------------------------
+// Browser setup
+// ---------------------------------------------------------------------------
+
+let browser: Browser
+
+// Find chromium installed by Playwright
+const chromiumPath = (() => {
+ const cacheDir = path.join(process.env.HOME || "/root", ".cache", "ms-playwright")
+ try {
+ const dirs = fs.readdirSync(cacheDir).filter((d) => d.startsWith("chromium-"))
+ if (dirs.length) return path.join(cacheDir, dirs[0], "chrome-linux", "chrome")
+ } catch {}
+ return ""
+})()
+
+const canRunBrowserTests = chromiumPath && fs.existsSync(chromiumPath)
+
+beforeAll(async () => {
+ if (!canRunBrowserTests) return
+ browser = await chromium.launch({
+ headless: true,
+ executablePath: chromiumPath,
+ args: ["--no-sandbox"],
+ })
+})
+
+afterAll(async () => {
+ if (browser) await browser.close()
+})
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function baseTrace(overrides?: Partial
): TraceFile {
+ return {
+ version: "1.0",
+ sessionId: "test-session",
+ startedAt: new Date(1000).toISOString(),
+ endedAt: new Date(6000).toISOString(),
+ metadata: {
+ title: "Test Session",
+ prompt: "Hello world",
+ model: "anthropic/claude-3",
+ providerId: "anthropic",
+ agent: "default",
+ },
+ summary: {
+ status: "completed",
+ duration: 5000,
+ totalTokens: 10000,
+ totalCost: 0.05,
+ totalGenerations: 2,
+ totalToolCalls: 2,
+ tokens: { input: 5000, output: 3000, reasoning: 500, cacheRead: 1000, cacheWrite: 500 },
+ },
+ spans: [
+ {
+ spanId: "sess-1",
+ parentSpanId: null,
+ kind: "session",
+ name: "Session",
+ startTime: 1000,
+ endTime: 6000,
+ status: "ok",
+ },
+ {
+ spanId: "gen-1",
+ parentSpanId: "sess-1",
+ kind: "generation",
+ name: "Generation 1",
+ startTime: 1100,
+ endTime: 2500,
+ status: "ok",
+ tokens: { input: 2000, output: 1000, total: 3000 },
+ cost: 0.02,
+ input: "User asks a question",
+ output: "Agent responds with an answer",
+ },
+ {
+ spanId: "tool-1",
+ parentSpanId: "gen-1",
+ kind: "tool",
+ name: "ReadFile",
+ startTime: 1500,
+ endTime: 2000,
+ status: "ok",
+ tool: { callId: "call-1", durationMs: 500 },
+ input: { path: "/src/index.ts" },
+ output: "const app = express();",
+ },
+ {
+ spanId: "gen-2",
+ parentSpanId: "sess-1",
+ kind: "generation",
+ name: "Generation 2",
+ startTime: 2600,
+ endTime: 5500,
+ status: "ok",
+ tokens: { input: 3000, output: 2000, total: 5000 },
+ cost: 0.03,
+ input: "Follow-up question",
+ output: "Follow-up answer",
+ },
+ {
+ spanId: "tool-2",
+ parentSpanId: "gen-2",
+ kind: "tool",
+ name: "WriteFile",
+ startTime: 3000,
+ endTime: 4500,
+ status: "ok",
+ tool: { callId: "call-2", durationMs: 1500 },
+ input: { path: "/src/out.ts", content: "done" },
+ output: "Written successfully",
+ },
+ ],
+ ...overrides,
+ } as TraceFile
+}
+
+async function openViewer(trace: TraceFile, opts?: { live?: boolean }): Promise {
+ const page = await browser.newPage()
+ const html = renderTraceViewer(trace, opts)
+ await page.setContent(html)
+ await page.waitForSelector(".tabs")
+ return page
+}
+
+/** Click a waterfall row by index and return the detail panel title */
+async function clickWfRow(page: Page, index: number) {
+ await page.locator(".wf-row").nth(index).click()
+ return page.evaluate(() => document.querySelector(".detail-panel h3")?.textContent ?? null)
+}
+
+/** Click a tree item by index and return the detail panel title */
+async function clickTreeItem(page: Page, index: number) {
+ await page.locator(".tree-item").nth(index).click()
+ return page.evaluate(() => document.querySelector(".detail-panel h3")?.textContent ?? null)
+}
+
+/** Click a log entry by index and return the detail panel title */
+async function clickLogEntry(page: Page, index: number) {
+ await page.locator(".log-entry").nth(index).click()
+ return page.evaluate(() => document.querySelector(".detail-panel h3")?.textContent ?? null)
+}
+
+/** Get the active tab name */
+async function activeTab(page: Page) {
+ return page.evaluate(() => document.querySelector(".tab.active")?.getAttribute("data-view"))
+}
+
+/** Get the active view id */
+async function activeView(page: Page) {
+ return page.evaluate(() => document.querySelector(".view.active")?.id)
+}
+
+/** Count JS errors on the page */
+function collectErrors(page: Page): string[] {
+ const errors: string[] = []
+ page.on("pageerror", (err) => errors.push(err.message))
+ return errors
+}
+
+// ---------------------------------------------------------------------------
+// E2E Tests — User perspective
+// ---------------------------------------------------------------------------
+
+describe.skipIf(!canRunBrowserTests)("Trace Viewer E2E", () => {
+ test("renders page without JS errors", async () => {
+ const page = await openViewer(baseTrace())
+ const errors = collectErrors(page)
+ await new Promise((r) => setTimeout(r, 200))
+ expect(errors).toEqual([])
+ await page.close()
+ })
+
+ test("default view is waterfall", async () => {
+ const page = await openViewer(baseTrace())
+ expect(await activeTab(page)).toBe("waterfall")
+ expect(await activeView(page)).toBe("v-waterfall")
+ await page.close()
+ })
+
+ test("renders correct number of waterfall rows (excludes session)", async () => {
+ const page = await openViewer(baseTrace())
+ const count = await page.locator(".wf-row").count()
+ // 4 non-session spans: gen-1, tool-1, gen-2, tool-2
+ expect(count).toBe(4)
+ await page.close()
+ })
+
+ test("clicking each waterfall row shows correct detail", async () => {
+ const page = await openViewer(baseTrace())
+ const names = ["Generation 1", "ReadFile", "Generation 2", "WriteFile"]
+ for (let i = 0; i < names.length; i++) {
+ const detail = await clickWfRow(page, i)
+ expect(detail).toBe(names[i])
+ }
+ await page.close()
+ })
+
+ test("waterfall row selection is exclusive (only one .sel at a time)", async () => {
+ const page = await openViewer(baseTrace())
+ await clickWfRow(page, 0)
+ await clickWfRow(page, 2)
+ const selCount = await page.evaluate(() => document.querySelectorAll(".wf-row.sel").length)
+ expect(selCount).toBe(1)
+ const selName = await page.evaluate(
+ () => document.querySelector(".wf-row.sel .wf-name")?.textContent,
+ )
+ expect(selName).toBe("Generation 2")
+ await page.close()
+ })
+
+ test("tab switching works for all 4 tabs", async () => {
+ const page = await openViewer(baseTrace())
+ for (const tab of ["tree", "chat", "log", "waterfall"] as const) {
+ await page.click(`[data-view="${tab}"]`)
+ expect(await activeTab(page)).toBe(tab)
+ expect(await activeView(page)).toBe(`v-${tab}`)
+ }
+ await page.close()
+ })
+
+ test("tab switching clears detail panel", async () => {
+ const page = await openViewer(baseTrace())
+ await clickWfRow(page, 0)
+ const before = await page.evaluate(() => document.getElementById("detail")?.innerHTML)
+ expect(before).toBeTruthy()
+ await page.click('[data-view="tree"]')
+ const after = await page.evaluate(() => document.getElementById("detail")?.innerHTML)
+ expect(after).toBe("")
+ await page.close()
+ })
+
+ test("tree view shows correct items with proper nesting", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="tree"]')
+ const treeItems = await page.evaluate(() =>
+ Array.from(document.querySelectorAll(".tree-item")).map((el) => ({
+ title: el.querySelector(".tree-title")?.textContent,
+ idx: (el as HTMLElement).dataset.idx,
+ })),
+ )
+ expect(treeItems.length).toBe(4) // 4 non-session spans
+ expect(treeItems.map((t) => t.title)).toEqual([
+ "Generation 1",
+ "ReadFile",
+ "Generation 2",
+ "WriteFile",
+ ])
+ // Each idx should be a valid number
+ for (const item of treeItems) {
+ expect(Number(item.idx)).toBeGreaterThanOrEqual(0)
+ }
+ await page.close()
+ })
+
+ test("clicking each tree item shows correct detail", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="tree"]')
+ const names = ["Generation 1", "ReadFile", "Generation 2", "WriteFile"]
+ for (let i = 0; i < names.length; i++) {
+ const detail = await clickTreeItem(page, i)
+ expect(detail).toBe(names[i])
+ }
+ await page.close()
+ })
+
+ test("clicking tree items in reverse order shows correct detail", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="tree"]')
+ // Click last first, then first — this is the "always expands last node" test
+ const last = await clickTreeItem(page, 3)
+ expect(last).toBe("WriteFile")
+ const first = await clickTreeItem(page, 0)
+ expect(first).toBe("Generation 1")
+ const middle = await clickTreeItem(page, 1)
+ expect(middle).toBe("ReadFile")
+ await page.close()
+ })
+
+ test("log entries are clickable and show detail", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="log"]')
+ const logCount = await page.locator(".log-entry").count()
+ expect(logCount).toBe(4)
+ // Click first log entry
+ const detail = await clickLogEntry(page, 0)
+ expect(detail).toBeTruthy()
+ // Verify selection highlight
+ const selCount = await page.evaluate(() => document.querySelectorAll(".log-entry.sel").length)
+ expect(selCount).toBe(1)
+ await page.close()
+ })
+
+ test("log entries sorted by startTime show correct detail on click", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="log"]')
+ const entries = await page.evaluate(() =>
+ Array.from(document.querySelectorAll(".log-entry")).map((el) => ({
+ name: el.querySelector(".log-name")?.textContent,
+ idx: (el as HTMLElement).dataset.idx,
+ })),
+ )
+ // Check each entry points to the right span
+ for (let i = 0; i < entries.length; i++) {
+ const detail = await clickLogEntry(page, i)
+ expect(detail).toBe(entries[i].name)
+ }
+ await page.close()
+ })
+
+ test("chat view shows prompt and generation output", async () => {
+ const page = await openViewer(baseTrace())
+ await page.click('[data-view="chat"]')
+ const promptText = await page.evaluate(
+ () => document.querySelector(".chat-msg.user .chat-bubble")?.textContent,
+ )
+ expect(promptText).toBe("Hello world")
+ const agentBubbles = await page.locator(".chat-msg.agent .chat-bubble").count()
+ expect(agentBubbles).toBeGreaterThanOrEqual(1)
+ await page.close()
+ })
+
+ test("detail panel shows input/output when present", async () => {
+ const page = await openViewer(baseTrace())
+ await clickWfRow(page, 0) // Generation 1
+ const hasInput = await page.evaluate(() => !!document.querySelector(".sec-lbl"))
+ expect(hasInput).toBe(true)
+ const inputText = await page.evaluate(() => {
+ const pres = document.querySelectorAll("pre.io")
+ return pres[0]?.textContent ?? ""
+ })
+ expect(inputText).toContain("User asks a question")
+ await page.close()
+ })
+
+ test("summary cards display correct values", async () => {
+ const page = await openViewer(baseTrace())
+ const cards = await page.evaluate(() =>
+ Array.from(document.querySelectorAll(".card")).map((c) => ({
+ label: c.querySelector(".lbl")?.textContent,
+ value: c.querySelector(".val")?.textContent,
+ })),
+ )
+ const durationCard = cards.find((c) => c.label === "Duration")
+ expect(durationCard).toBeDefined()
+ expect(durationCard!.value).toBe("5.0s")
+ const costCard = cards.find((c) => c.label === "Cost")
+ expect(costCard).toBeDefined()
+ expect(costCard!.value).toBe("$0.05")
+ await page.close()
+ })
+
+ test("header tags show session metadata", async () => {
+ const page = await openViewer(baseTrace())
+ const tagsText = await page.evaluate(() => document.getElementById("tags")?.textContent ?? "")
+ expect(tagsText).toContain("Test Session")
+ expect(tagsText).toContain("anthropic")
+ expect(tagsText).toContain("claude-3")
+ await page.close()
+ })
+
+ test("rapid tab switching does not break state", async () => {
+ const page = await openViewer(baseTrace())
+ const errors = collectErrors(page)
+ // Rapid switching
+ for (let i = 0; i < 10; i++) {
+ for (const tab of ["waterfall", "tree", "chat", "log"]) {
+ await page.click(`[data-view="${tab}"]`)
+ }
+ }
+ expect(errors).toEqual([])
+ // Verify final state is log
+ expect(await activeTab(page)).toBe("log")
+ await page.close()
+ })
+
+ test("clicking between views preserves correct behavior", async () => {
+ const page = await openViewer(baseTrace())
+ // Click WF row, switch to tree, click tree item, switch to log, click log entry
+ await clickWfRow(page, 0)
+ expect(await page.evaluate(() => document.querySelector(".detail-panel h3")?.textContent)).toBe(
+ "Generation 1",
+ )
+
+ await page.click('[data-view="tree"]')
+ await clickTreeItem(page, 3)
+ expect(await page.evaluate(() => document.querySelector(".detail-panel h3")?.textContent)).toBe(
+ "WriteFile",
+ )
+
+ await page.click('[data-view="log"]')
+ await clickLogEntry(page, 0)
+ const logDetail = await page.evaluate(
+ () => document.querySelector(".detail-panel h3")?.textContent,
+ )
+ expect(logDetail).toBeTruthy()
+ await page.close()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Adversarial Tests — Edge cases & malicious data
+// ---------------------------------------------------------------------------
+
+describe.skipIf(!canRunBrowserTests)("Trace Viewer Adversarial", () => {
+ test("empty spans array renders without errors", async () => {
+ const page = await openViewer(baseTrace({ spans: [] }))
+ const errors = collectErrors(page)
+ await new Promise((r) => setTimeout(r, 200))
+ expect(errors).toEqual([])
+ const wfCount = await page.locator(".wf-row").count()
+ expect(wfCount).toBe(0)
+ await page.click('[data-view="tree"]')
+ const treeText = await page.evaluate(() => document.getElementById("v-tree")?.textContent ?? "")
+ expect(treeText).toContain("No spans recorded")
+ await page.close()
+ })
+
+ test("single span (session only) renders without errors", async () => {
+ const trace = baseTrace({
+ spans: [
+ {
+ spanId: "s1",
+ parentSpanId: null,
+ kind: "session",
+ name: "Lonely Session",
+ startTime: 1000,
+ endTime: 2000,
+ status: "ok",
+ },
+ ],
+ })
+ const page = await openViewer(trace)
+ const errors = collectErrors(page)
+ await new Promise((r) => setTimeout(r, 200))
+ expect(errors).toEqual([])
+ expect(await page.locator(".wf-row").count()).toBe(0)
+ await page.close()
+ })
+
+ test("duplicate spanIds: each tree item still shows correct detail", async () => {
+ const trace = baseTrace({
+ spans: [
+ {
+ spanId: "s",
+ parentSpanId: null,
+ kind: "session",
+ name: "Session",
+ startTime: 1000,
+ endTime: 3000,
+ status: "ok",
+ },
+ {
+ spanId: "dup",
+ parentSpanId: "s",
+ kind: "generation",
+ name: "First-DUP",
+ startTime: 1000,
+ endTime: 2000,
+ status: "ok",
+ output: "out1",
+ },
+ {
+ spanId: "dup",
+ parentSpanId: "s",
+ kind: "generation",
+ name: "Second-DUP",
+ startTime: 2000,
+ endTime: 3000,
+ status: "ok",
+ output: "out2",
+ },
+ ],
+ })
+ const page = await openViewer(trace)
+ // Waterfall
+ expect(await clickWfRow(page, 0)).toBe("First-DUP")
+ expect(await clickWfRow(page, 1)).toBe("Second-DUP")
+ // Tree
+ await page.click('[data-view="tree"]')
+ expect(await clickTreeItem(page, 0)).toBe("First-DUP")
+ expect(await clickTreeItem(page, 1)).toBe("Second-DUP")
+ await page.close()
+ })
+
+ test("XSS in span names is escaped", async () => {
+ const trace = baseTrace({
+ spans: [
+ {
+ spanId: "s",
+ parentSpanId: null,
+ kind: "session",
+ name: "Session",
+ startTime: 1000,
+ endTime: 3000,
+ status: "ok",
+ },
+ {
+ spanId: "xss",
+ parentSpanId: "s",
+ kind: "generation",
+ name: '',
+ startTime: 1000,
+ endTime: 2000,
+ status: "ok",
+ output: '
',
+ },
+ ],
+ })
+ const page = await openViewer(trace)
+ const errors = collectErrors(page)
+ await new Promise((r) => setTimeout(r, 200))
+ expect(errors).toEqual([])
+ // The script tag should be rendered as text, not executed
+ const wfName = await page.evaluate(
+ () => document.querySelector(".wf-name")?.textContent ?? "",
+ )
+ expect(wfName).toContain("',
+ prompt: '">
',
+ model: "anthropic/claude-3",
+ providerId: "anthropic",
+ agent: "default",
+ },
+ } as any)
+ const page = await openViewer(trace)
+ const errors = collectErrors(page)
+ await new Promise((r) => setTimeout(r, 200))
+ expect(errors).toEqual([])
+ const tags = await page.evaluate(() => document.getElementById("tags")?.innerHTML ?? "")
+ expect(tags).not.toContain(" in JSON data", () => {
+ const trace = baseTrace({
+ metadata: {
+ ...baseTrace().metadata,
+ prompt: 'Test ',
+ },
+ } as any)
+ const html = renderTraceViewer(trace)
+ // Should not contain raw inside the script tag
+ const scriptContent = html.match(/ should be the closing tag itself
+ expect(scriptContent).not.toContain("")
+ // But should contain the escaped version
+ expect(scriptContent).toContain("<\\/script>")
+ })
+
+ test("unicode in span names renders correctly", async () => {
+ const trace = baseTrace({
+ spans: [
+ {
+ spanId: "s",
+ parentSpanId: null,
+ kind: "session",
+ name: "Session",
+ startTime: 1000,
+ endTime: 2000,
+ status: "ok",
+ },
+ {
+ spanId: "uni",
+ parentSpanId: "s",
+ kind: "generation",
+ name: "日本語テスト 🎉 émojis",
+ startTime: 1000,
+ endTime: 2000,
+ status: "ok",
+ },
+ ],
+ })
+ const page = await openViewer(trace)
+ const detail = await clickWfRow(page, 0)
+ expect(detail).toBe("日本語テスト 🎉 émojis")
+ await page.close()
+ })
+})