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) + '
' + '
' + 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() + }) +})