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>
118 lines
6.8 KiB
JavaScript
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);
|
|
});
|