import assert from 'node:assert/strict'; import { mkdir, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; // Local production preview; all APIs/sockets are synthetic and external requests are blocked. // PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs WEB_TEST_URL=http://127.0.0.1:5196 // TRANSCRIPT_EVIDENCE=/tmp/evidence TRANSCRIPT_BASELINE=1 node tests/agent-transcript.browser.mjs const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5196/'); assert.ok(target.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)); const baseline = process.env.TRANSCRIPT_BASELINE === '1'; const evidence = process.env.TRANSCRIPT_EVIDENCE; if (evidence) await mkdir(evidence, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' }); const page = await context.newPage(); const errors = [], requests = [], reads = [], socketCursors = []; page.on('pageerror', error => errors.push(error.message)); const idle = { state: 'idle', isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; const sourceId = '00000000-0000-4000-8000-000000000001'; const row = (id, content) => ({ id: `entry:${id}`, role: id % 3 ? 'assistant' : 'user', createdAt: new Date(Date.UTC(2026, 9, 16, 0, 0, id)).toISOString(), content: content || `Fixture message ${id}\n\n${'Synthetic transcript text for delayed loading. '.repeat(id % 11 === 0 ? 30 : 3)}`, metadata: { kind: 'message', entryId: id, status: 'complete' } }); const history = Array.from({ length: 1200 }, (_, i) => row(i + 1)); let live, readCursor = '0', failOlder = false, imageBytes, emptyRecent = false; const start = performance.now(); const elapsed = () => Math.round(performance.now() - start); await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); window.fixtureTiming = {}; const sample = () => { const scroller = document.querySelector('.agent-room__scroll'); if (scroller) { const box = scroller.getBoundingClientRect(); const visible = [...scroller.querySelectorAll('[data-agent-message-id]')].filter(e => { const r = e.getBoundingClientRect(); return r.bottom > box.top && r.top < box.bottom; }); if (visible.length && !window.fixtureTiming.first) window.fixtureTiming.first = { ms: performance.now(), ids: visible.map(e => e.dataset.agentMessageId) }; if (visible.some(e => e.dataset.agentMessageId === 'entry:1200') && !window.fixtureTiming.latestMs) window.fixtureTiming.latestMs = performance.now(); } if (!window.fixtureTiming.latestMs) requestAnimationFrame(sample); }; requestAnimationFrame(sample); }); await page.route('**/*', async route => { const req = route.request(), url = new URL(req.url()); if (url.origin !== target.origin) return route.abort(); if (!url.pathname.startsWith('/api/')) return route.continue(); const path = url.pathname, method = req.method(); assert.equal(req.headers().authorization, undefined, 'Fixture must never use a real credential'); let value; if (path.endsWith('/content')) { await new Promise(resolve => setTimeout(resolve, 600)); return route.fulfill({ contentType: 'image/png', body: imageBytes }); } else if (path.endsWith('/agent/messages')) { assert.equal(method, 'GET', 'Fixture must not send messages'); const after = url.searchParams.get('after'), before = url.searchParams.get('before'), limit = Number(url.searchParams.get('limit') || 200); const record = { query: url.search, requestedMs: elapsed() }; requests.push(record); await new Promise(resolve => setTimeout(resolve, before ? 1600 : 800)); if (before && failOlder) { failOlder = false; return route.fulfill({ status: 503, json: { message: 'Fixture older page unavailable' } }); } const eligible = history.filter(m => after !== null ? m.metadata.entryId > Number(after) : before !== null ? m.metadata.entryId < Number(before) : true); const messages = after !== null ? eligible.slice(0, limit) : eligible.slice(-limit); value = { conversationId: 'fixture-room', messages, cursor: String(messages.at(-1)?.metadata.entryId ?? after ?? 0), beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: eligible.length > messages.length }; if (emptyRecent && before === null && after === null) value.messages = []; Object.assign(record, { responseMs: elapsed(), rows: messages.length, bytes: Buffer.byteLength(JSON.stringify(value)) }); } else if (path.endsWith('/read-state')) { if (method === 'PUT') { readCursor = req.postDataJSON().cursor; reads.push({ cursor: readCursor, ms: elapsed() }); } value = { cursor: readCursor, sourceId, updatedAt: null }; } else if (path.endsWith('/agent')) value = { id: 'fixture-room', title: 'Sybil Agent', activity: idle, protocol: 'pi-room.v1', websocket: '/v1/agent/ws' }; else if (path.endsWith('/auth/session')) value = { authenticated: true, mode: 'open' }; else if (path.endsWith('/models')) value = { models: [], providers: {} }; else if (path.endsWith('/tools') || path.endsWith('/chat-tools')) value = { tools: [] }; else value = []; await route.fulfill({ json: value }); }); await page.routeWebSocket('**/api/v1/agent/ws', socket => { live = socket; socket.onMessage(raw => { const frame = JSON.parse(String(raw)); if (frame.type === 'auth') { assert.equal(frame.token, null); socketCursors.push(frame.after); socket.send(JSON.stringify({ type: 'ready', conversationId: 'fixture-room' })); } }); }); const send = messages => { for (const message of messages) { const index = history.findIndex(m => m.id === message.id); if (index < 0) history.push(message); else history[index] = message; } live.send(JSON.stringify({ type: 'messages', conversationId: 'fixture-room', messages, cursor: String(messages.at(-1).metadata.entryId), beforeCursor: String(messages[0].metadata.entryId), hasMore: false })); }; const position = () => page.locator('.agent-room__scroll').evaluate(el => ({ top: el.scrollTop, distance: el.scrollHeight - el.scrollTop - el.clientHeight, rows: el.querySelectorAll('[data-agent-entry-id]').length })); const anchor = () => page.locator('.agent-room__scroll').evaluate(el => { const top = el.getBoundingClientRect().top; const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.getBoundingClientRect().bottom > top + 8); return { id: entry.dataset.agentEntryId, offset: entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - top }; }); const anchorDrift = saved => page.locator('.agent-room__scroll').evaluate((el, saved) => { const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.dataset.agentEntryId === saved.id); return entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - el.getBoundingClientRect().top - saved.offset; }, saved); const metrics = { mode: baseline ? 'before' : 'after', fixtureRows: 1200, pageDelayMs: 800, olderDelayMs: 1600 }; try { await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' }); await page.locator('[data-testid="agent-sidebar-item"]').click(); await page.waitForFunction(() => window.fixtureTiming?.first); if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-first.png` }); await page.waitForFunction(() => window.fixtureTiming?.latestMs, { timeout: 20000 }); await page.getByRole('status').filter({ hasText: /^Connected$/ }).waitFor(); await page.waitForTimeout(400); Object.assign(metrics, await page.evaluate(() => window.fixtureTiming), { initial: await position(), initialRequests: requests.map(r => ({ ...r })), socketCursors: [...socketCursors] }); if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-latest.png` }); if (!baseline) { assert.equal(requests.length, 1, 'No eager older/forward chain on initial load'); assert.equal(new URLSearchParams(requests[0].query).has('after'), false); assert.equal(socketCursors[0], '1200'); assert.ok(metrics.initial.rows <= 100); assert.ok(metrics.initial.distance < 2); await page.waitForTimeout(1800); assert.equal(requests.length, 1, 'Idle initial view must not drain history'); await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); await page.waitForTimeout(150); const saved = await anchor(), readBefore = readCursor; await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); send([row(1201)]); await page.waitForTimeout(500); assert.equal(readCursor, readBefore, 'Incoming while reading must stay unread'); metrics.incomingDriftPx = await anchorDrift(saved); await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor(); await page.waitForTimeout(400); metrics.prependDriftPx = await anchorDrift(saved); metrics.afterPrepend = await position(); assert.ok(Math.abs(metrics.incomingDriftPx) < 2); assert.ok(Math.abs(metrics.prependDriftPx) < 2); assert.equal(readCursor, readBefore); assert.equal(requests.length, 2, 'One user action loads one older page'); assert.equal(await page.locator('[data-agent-entry-id="entry:1001"] [data-animation="enter"]').count(), 0); const beforeGrowth = await anchor(); send([row(1201, 'Dynamic streamed content.\n\n'.repeat(80))]); await page.waitForTimeout(350); metrics.streamingReadingDriftPx = await anchorDrift(beforeGrowth); assert.ok(Math.abs(metrics.streamingReadingDriftPx) < 2); await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; }); await page.waitForTimeout(250); send([row(1201, 'Dynamic streamed content continues.\n\n'.repeat(100))]); await page.waitForTimeout(400); metrics.streamingPinnedDistancePx = (await position()).distance; assert.ok(metrics.streamingPinnedDistancePx < 2); const order = await page.locator('.agent-room__timeline').evaluate(el => { const ids = [...el.querySelectorAll('[data-agent-entry-id]')].map(e => Number(e.dataset.agentEntryId.split(':')[1])); const range = document.createRange(); range.selectNodeContents(el); return { ids, text: range.toString(), direction: getComputedStyle(el).flexDirection, skipped: [...el.querySelectorAll('[data-agent-message-id]')].filter(e => !e.checkVisibility({ contentVisibilityAuto: true })).length }; }); assert.deepEqual(order.ids, [...order.ids].sort((a, b) => a - b)); assert.ok(order.text.indexOf('Fixture message 1001') < order.text.indexOf('Fixture message 1200')); assert.equal(order.direction, 'column'); assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0); metrics.skippedOffscreenRows = order.skipped; assert.ok(order.skipped > 0, 'Offscreen rows should skip layout/paint'); const ax = await page.locator('.agent-room__scroll').ariaSnapshot(); assert.ok(ax.includes('Fixture message 1001') && ax.includes('Fixture message 1200')); assert.ok(ax.indexOf('Fixture message 1001') < ax.indexOf('Fixture message 1200')); // Authenticated attachment bytes are synthetic, delayed, and never fetched off origin. imageBytes = Buffer.from(await page.evaluate(() => { const canvas = document.createElement('canvas'); canvas.width = 240; canvas.height = 180; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#8974da'; ctx.fillRect(0, 0, 240, 180); return canvas.toDataURL('image/png').split(',')[1]; }), 'base64'); const file = { id: 'att_12345678-1234-4234-8234-123456789abc', filename: 'fixture.png', mimeType: 'image/png', kind: 'image', sizeBytes: imageBytes.length, sha256: createHash('sha256').update(imageBytes).digest('hex') }; send([{ ...row(1202), metadata: { ...row(1202).metadata, agentAttachments: [file] } }]); await page.waitForTimeout(500); await page.getByRole('button', { name: 'Preview', exact: true }).click(); await page.locator('.agent-attachment__preview').waitFor(); await page.waitForTimeout(250); metrics.attachmentPinnedDistancePx = (await position()).distance; assert.ok(metrics.attachmentPinnedDistancePx < 2); send([row(1203, 'Following the delayed attachment.\n\n'.repeat(60))]); await page.waitForTimeout(500); await page.locator('.agent-room__scroll').evaluate(el => { const entry = el.querySelector('[data-agent-entry-id="entry:1203"]'); el.scrollTop += entry.getBoundingClientRect().top - el.getBoundingClientRect().top + 80; }); await page.waitForTimeout(200); const attachmentAnchor = await anchor(); // Collapse an earlier preview while reading the following message. await page.getByRole('button', { name: 'Hide preview', exact: true }).evaluate(el => el.click()); await page.waitForTimeout(300); metrics.attachmentReadingDriftPx = await anchorDrift(attachmentAnchor); assert.ok(Math.abs(metrics.attachmentReadingDriftPx) < 2); // A failed older page is locally retryable without clearing the recent window. await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); await page.waitForTimeout(200); const rowsBeforeFailure = (await position()).rows; failOlder = true; await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); await page.getByRole('alert').filter({ hasText: 'Fixture older page unavailable' }).waitFor(); assert.equal((await position()).rows, rowsBeforeFailure); const socketsBefore = socketCursors.length; await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); await page.waitForTimeout(150); live.close({ code: 1012, reason: 'Fixture reconnect' }); await page.getByRole('button', { name: 'Reconnect to Sybil Agent', exact: true }).click(); await page.waitForTimeout(2200); assert.ok(socketCursors.length > socketsBefore); assert.equal(socketCursors.at(-1), '1203'); assert.equal((await position()).rows, rowsBeforeFailure, 'Retired older response must not publish into the new session'); assert.equal(await page.getByRole('alert').count(), 0); await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; }); await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[data-agent-entry-id="entry:901"]')); metrics.retiredOlderIgnored = true; metrics.olderFailureRetry = true; assert.equal(requests.filter(r => r.query.includes('after=0')).length, 0); await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; }); await page.waitForTimeout(200); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(400); metrics.mobilePinnedDistancePx = (await position()).distance; assert.ok(metrics.mobilePinnedDistancePx < 2); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0); if (evidence) await page.screenshot({ path: `${evidence}/after-mobile.png` }); // A source window can contain only internal entries. It stays bounded and // must not mark unloaded public history read or animate an older prepend. emptyRecent = true; readCursor = '0'; const requestsBeforeEmpty = requests.length; await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' }); await page.locator('[data-testid="agent-sidebar-item"]').click(); await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor(); await page.waitForTimeout(1000); assert.equal((await position()).rows, 0); assert.equal(requests.length, requestsBeforeEmpty + 1); assert.equal(readCursor, '0'); await page.getByRole('button', { name: 'Load older messages', exact: true }).click(); await page.waitForFunction(() => document.querySelectorAll('[data-agent-entry-id]').length > 0); assert.equal(await page.locator('[data-animation="enter"]').count(), 0); metrics.internalOnlyWindowBounded = true; metrics.internalOnlyWindowUnacknowledged = true; } assert.deepEqual(errors, []); metrics.requests = requests; metrics.reads = reads; metrics.errors = errors; if (evidence) await writeFile(`${evidence}/${metrics.mode}.json`, JSON.stringify(metrics, null, 2)); console.log(JSON.stringify(metrics, null, 2)); } catch (error) { console.error(JSON.stringify({ metrics, requests, errors, body: (await page.locator('body').innerText()).slice(0, 1800) })); throw error; } finally { await context.close(); await browser.close(); }