Files

251 lines
17 KiB
JavaScript
Raw Permalink Normal View History

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(); }