tts-voice-creator-clone-and.../tests/conversation-metrics.spec.js
mARTin-B78 caee289bcc fix(conversation): keep Turn history rows visible in the Latency column
The rows were added to the DOM but the overflow:auto list was shrunk to zero
height by the flex column panel once the charts filled it. Adds tests for
#n + time rows, error rows and stats arriving after done.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 02:14:55 +02:00

118 lines
6.8 KiB
JavaScript

// @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);
});