// @ts-check // Conversation Playground Metrics view (all /api routes mocked; no live writes). const { test, expect } = require('./_guard'); const sse = (/** @type {any[]} */ evts) => evts.map(e => `data: ${JSON.stringify(e)}\n\n`).join(''); async function boot(page, { usage = true, lang = '', errorTurns = [], statsAfterDone = false } = {}) { let n = 0; await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } })); await page.route('**/api/conversation/turn', route => { n++; if (errorTurns.includes(n)) return route.fulfill({ contentType: 'text/event-stream', body: sse([{ type: 'error', stage: 'llm', message: 'boom' }]) }); const stats = { type: 'stats', stt_ms: 100 + n, llm_ttft_ms: 200 + n * 10, llm_total_ms: 900, tts_ms: 300 + n, total_ms: 1500, usage: usage ? { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } : null }; return route.fulfill({ contentType: 'text/event-stream', body: sse([ { type: 'transcript', text: 'hi', stt_ms: null }, { type: 'token', delta: 'Hello.' }, { type: 'llm_done', text: 'Hello.', llm_ttft_ms: 1, llm_total_ms: 2 }, ...(statsAfterDone ? [{ type: 'done' }, stats] : [stats, { type: 'done' }])]) }); }); await page.route('**/static/dist/main.min.js*', r => r.abort()); if (lang) await page.addInitScript(l => { try { localStorage.setItem('app-lang', l); } catch (e) {} }, lang); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await page.evaluate(() => window.navTo('s-conversation')); } async function turn(page, i) { await page.locator('#conv-text-input').fill('hello ' + i); await page.locator('#conv-text-input').press('Enter'); await expect(page.locator('#conv-turn-history .conv-hist-item')).toHaveCount(i); await expect(page.locator('#conv-turn-history .conv-hist-item').first().locator('.conv-hist-num')).toHaveText('#' + i); await expect(page.locator('#conv-send-btn')).toBeEnabled(); } test('3 turns -> 3 charts with 3 points, token sums, latency panel intact', async ({ page }) => { await boot(page); for (let i = 1; i <= 3; i++) await turn(page, i); // existing Latency panel unchanged for (const id of ['cpv-stt', 'cpv-ttft', 'cpv-llm', 'cpv-tts', 'cpv-total']) await expect(page.locator('#' + id)).not.toHaveText('—'); await expect(page.locator('#conv-stats-panel .conv-stats-head', { hasText: 'Turn history' })).toBeVisible(); await expect(page.getByRole('tab')).toHaveCount(0); await expect(page.locator('#conv-stats-panel .conv-chart')).toHaveCount(3); for (const k of ['stt', 'llm', 'tts']) { await expect(page.locator(`.conv-chart[data-metric="${k}"] circle`)).toHaveCount(3); await expect(page.locator(`.conv-chart[data-metric="${k}"] tbody tr`)).toHaveCount(3); } await expect(page.locator('.conv-chart[data-metric="llm"] svg')).toHaveAttribute('aria-label', 'LLM first token, last 230 ms, 3 points'); await expect(page.locator('.conv-chart[data-metric="llm"] svg')).toContainText('Time (ms)'); await expect(page.locator('.conv-chart[data-metric="llm"] svg')).toContainText('TTFB (LLM)'); const d = page.locator('.conv-chart[data-metric="stt"] details'); await expect(d).not.toHaveAttribute('open', ''); await d.locator('summary').click(); await expect(d.locator('table')).toBeVisible(); await expect(page.locator('#cm-tok-prompt')).toHaveText('30'); await expect(page.locator('#cm-tok-completion')).toHaveText('15'); await expect(page.locator('#cm-tok-total')).toHaveText('45'); expect(await page.locator('.conv-chart[data-metric="stt"] tbody td').first().textContent()).toMatch(/^\d{2}:\d{2}:\d{2}$/); // Clear resets await page.locator('#conv-clear-btn').click(); await expect(page.locator('.conv-chart')).toHaveCount(0); await expect(page.locator('#cm-empty')).toBeVisible(); await expect(page.locator('#cm-tok-total')).toHaveText('n/a'); }); test('no usage from the server shows n/a, never 0', async ({ page }) => { await boot(page, { usage: false }); await turn(page, 1); for (const id of ['prompt', 'completion', 'total']) await expect(page.locator('#cm-tok-' + id)).toHaveText('n/a'); await expect(page.locator('.conv-chart[data-metric="llm"] circle')).toHaveCount(1); }); test('empty state and German labels', async ({ page }) => { await boot(page, { lang: 'de' }); await expect(page.locator('#cm-empty')).toBeVisible(); await expect(page.locator('#cm-tok-total')).toHaveText('k. A.'); await expect(page.locator('#conv-stats-panel .conv-stats-head', { hasText: 'Token-Verbrauch' })).toBeVisible(); }); test('turn history rows are visible with #n and a formatted time (normal and error turns)', async ({ page }) => { await boot(page, { errorTurns: [3] }); for (let i = 1; i <= 3; i++) await turn(page, i); const rows = page.locator('#conv-turn-history .conv-hist-item'); for (let i = 0; i < 3; i++) { const row = rows.nth(i); // newest first await expect(row).toBeVisible(); // not squeezed to 0 height by the flex panel await expect(row.locator('.conv-hist-num')).toHaveText('#' + (3 - i)); await expect(row.locator('.conv-hist-time')).toHaveText(/^\d+(\.\d+)?(ms|s)$/); } await expect(rows.first().locator('.conv-hist-err')).toHaveCount(2); // error row: icon + time await expect(rows.nth(1).locator('.conv-hist-ok')).toHaveCount(2); await expect(rows.nth(1).locator('.conv-hist-time')).toHaveText('1.50s'); }); test('stats arriving after done still yield a history row with a time', async ({ page }) => { await boot(page, { statsAfterDone: true }); await turn(page, 1); await expect(page.locator('#conv-turn-history .conv-hist-time').first()).toHaveText(/^\d+(\.\d+)?(ms|s)$/); }); test('chart text is legible in the narrow column, single point is drawn', async ({ page }) => { await boot(page); await turn(page, 1); const chart = page.locator('.conv-chart[data-metric="llm"]'); await expect(chart.locator('circle')).toHaveCount(1); const dot = await chart.locator('circle').boundingBox(); const box = await chart.locator('svg').boundingBox(); expect(dot && box && dot.width >= 6 && dot.x > box.x + 40 && dot.x < box.x + box.width).toBeTruthy(); await expect(chart.locator('svg')).toContainText('0'); const sizes = await chart.locator('svg text').evaluateAll(els => els.map(e => { const r = e.getBoundingClientRect(); return { fs: parseFloat(getComputedStyle(e).fontSize), scale: r.height / (e.getBBox().height || 1) }; })); const vb = await chart.locator('svg').evaluate(e => ({ vw: e.viewBox.baseVal.width, cw: e.getBoundingClientRect().width })); for (const s of sizes) expect(s.fs * (vb.cw / vb.vw)).toBeGreaterThanOrEqual(10); expect(Math.abs(vb.vw - vb.cw)).toBeLessThan(2); });