119 lines
8.5 KiB
JavaScript
119 lines
8.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { createServer } from 'vite';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const width = Number(process.env.GEOMETRY_WIDTH || 1200);
|
|
const height = Number(process.env.GEOMETRY_HEIGHT || 900);
|
|
const scrollAway = process.env.SCROLL_AWAY === '1';
|
|
const browser = process.env.CHROME_EXECUTABLE || [
|
|
'/usr/bin/chromium', '/usr/bin/google-chrome', '/usr/bin/chromium-browser',
|
|
].find(existsSync) || 'chromium';
|
|
const profile = await mkdtemp(`${tmpdir()}/sybil-agent-send-geometry-`);
|
|
const server = await createServer({ configFile: resolve(root, 'vite.config.ts'), root,
|
|
logLevel: 'error', server: { host: '127.0.0.1', port: 0, strictPort: false } });
|
|
let child;
|
|
let socket;
|
|
|
|
try {
|
|
await server.listen();
|
|
const address = server.httpServer.address();
|
|
assert.ok(address && typeof address === 'object');
|
|
const debugPort = await new Promise((resolvePort, reject) => {
|
|
const probe = spawn(browser, ['--headless', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1200,900',
|
|
'--remote-debugging-port=0', '--remote-allow-origins=*', `--user-data-dir=${profile}`, '--no-first-run']);
|
|
child = probe;
|
|
let diagnostics = '';
|
|
const timeout = setTimeout(() => reject(new Error(`Chrome startup failed: ${diagnostics.slice(-600)}`)), 10000);
|
|
probe.stderr.setEncoding('utf8').on('data', chunk => {
|
|
diagnostics += chunk;
|
|
const match = diagnostics.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
|
|
if (match) { clearTimeout(timeout); resolvePort(Number(match[1])); }
|
|
});
|
|
probe.once('error', error => { clearTimeout(timeout); reject(error); });
|
|
probe.once('close', code => { clearTimeout(timeout); reject(new Error(`Chrome exited (${code}): ${diagnostics.slice(-600)}`)); });
|
|
});
|
|
const targetResponse = await fetch(`http://127.0.0.1:${debugPort}/json/new?http://127.0.0.1:${address.port}/tests/agent-scroll-fixture.html`, { method: 'PUT' });
|
|
assert.equal(targetResponse.status, 200);
|
|
const target = await targetResponse.json();
|
|
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
await new Promise((resolveOpen, reject) => {
|
|
socket.addEventListener('open', resolveOpen, { once: true });
|
|
socket.addEventListener('error', reject, { once: true });
|
|
});
|
|
let commandId = 0;
|
|
const pending = new Map();
|
|
socket.addEventListener('message', event => {
|
|
const message = JSON.parse(event.data);
|
|
if (!message.id) return;
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
if (message.error) request.reject(new Error(`${request.method}: ${message.error.message}`));
|
|
else request.resolve(message.result);
|
|
});
|
|
const call = (method, params = {}) => new Promise((resolveCall, reject) => {
|
|
const id = ++commandId;
|
|
pending.set(id, { resolve: resolveCall, reject, method });
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
await call('Page.enable');
|
|
await call('Runtime.enable');
|
|
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 768 });
|
|
await call('Page.navigate', { url: `http://127.0.0.1:${address.port}/tests/agent-scroll-fixture.html?scrollAway=${scrollAway ? '1' : '0'}` });
|
|
const evaluated = await call('Runtime.evaluate', {
|
|
expression: `new Promise(resolve => { const deadline = Date.now() + 12000; const check = () => { if (document.documentElement.dataset.fixtureComplete === 'true') resolve({ result: JSON.parse(document.getElementById('result').textContent) }); else if (Date.now() >= deadline) resolve({ timedOut: true, error: document.documentElement.dataset.fixtureError || null, hasTimeline: Boolean(document.querySelector('.agent-room__timeline')), textAreaCount: document.querySelectorAll('textarea').length, roomSendCalled: document.documentElement.dataset.roomSendCalled || false, agentSubmitCalled: document.documentElement.dataset.agentSubmitCalled || false, entries: Array.from(document.querySelectorAll('[data-agent-entry-id]')).filter(entry => entry.dataset.agentEntryId.includes('fixture')).map(entry => ({ id: entry.dataset.agentEntryId, captured: entry.dataset.geometryCaptured || false, animations: entry.getAnimations().map(animation => ({ state: animation.playState, time: animation.currentTime, target: animation.effect?.target?.className || 'other' })) })), reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, resultText: document.getElementById('result')?.textContent }); else setTimeout(check, 25); }; check(); })`,
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
});
|
|
assert.equal(evaluated.exceptionDetails, undefined, evaluated.exceptionDetails?.text);
|
|
const page = evaluated.result.value;
|
|
assert.equal(page.timedOut, undefined, `fixture timed out: ${JSON.stringify(page)}`);
|
|
const result = page.result;
|
|
if (process.env.BASELINE_GEOMETRY !== '1') {
|
|
const byLabel = Object.fromEntries(result.samples.map(sample => [sample.label, sample]));
|
|
for (const label of ['user', 'agent']) {
|
|
assert.ok(byLabel[`${label}-start`] && byLabel[`${label}-mid`] && byLabel[`${label}-late`] && byLabel[`${label}-pre-end`] && byLabel[`${label}-finish`] && byLabel[`${label}-stable`], JSON.stringify(result.samples));
|
|
const endpoint = Number(String(byLabel[`${label}-start`].keyframeHeights[1]).replace('px', ''));
|
|
assert.ok(endpoint + 1 >= byLabel[`${label}-start`].contentHeight,
|
|
`${label} animation used a culling placeholder instead of its content height: ${JSON.stringify(result.samples)}`);
|
|
assert.equal(byLabel[`${label}-mid`].entryAnimationCount, 1, `${label} entrance animation stopped before the intermediate sample`);
|
|
assert.ok(Math.abs(byLabel[`${label}-finish`].rowHeight - byLabel[`${label}-stable`].rowHeight) < 1,
|
|
`${label} height moved after animation finish: ${JSON.stringify(result.samples)}`);
|
|
assert.ok(byLabel[`${label}-stable`].entryAnimationCount === 0, `${label} entry animation did not clean up`);
|
|
assert.ok(byLabel[`${label}-finish`].time - byLabel[`${label}-start`].time < 330,
|
|
`${label} entrance animation was prolonged by streaming updates: ${JSON.stringify(result.samples)}`);
|
|
if (scrollAway) {
|
|
assert.ok(Math.abs(Number(byLabel[`${label}-stable`].anchorShift)) < 1, `${label} reader anchor moved: ${JSON.stringify(result.samples)}`);
|
|
assert.ok(byLabel[`${label}-stable`].bottomGap > 100, `${label} reader was pulled to bottom: ${JSON.stringify(result.samples)}`);
|
|
} else assert.ok(byLabel[`${label}-stable`].bottomGap <= 1, `${label} pinned viewport left a gap: ${JSON.stringify(result.samples)}`);
|
|
}
|
|
assert.ok(byLabel['agent-late'].emptyHeight < 40, `Agent spacer persisted late in the entrance animation: ${JSON.stringify(result.samples)}`);
|
|
const retargetedEndpoint = Number(String(byLabel['agent-mid'].keyframeHeights.at(-1)).replace('px', ''));
|
|
assert.ok(byLabel['agent-mid'].keyframeHeights.length >= 3 && Math.abs(retargetedEndpoint - byLabel['agent-mid'].contentHeight) < 1,
|
|
`Agent animation did not retarget to the streamed message height: ${JSON.stringify(result.samples)}`);
|
|
assert.ok(Math.abs(byLabel['agent-pre-end'].emptyHeight) < 2, `Agent row changed height as the entrance animation finished: ${JSON.stringify(result.samples)}`);
|
|
assert.ok(Math.abs(byLabel['agent-stable'].emptyHeight) < 1, `Agent reply left an empty spacer: ${JSON.stringify(result.samples)}`);
|
|
assert.equal(result.roomSendCalled, true, 'composer did not use the real room send boundary');
|
|
assert.equal(result.agentSubmitCalled, true, 'optimistic outbox did not invoke its submission boundary');
|
|
assert.equal(result.composerFocused, true, 'composer focus was lost after send');
|
|
assert.equal(result.readDuringEntryAnimation, false, 'read state advanced during an entry animation');
|
|
assert.equal(byLabel['user-stable'].emptyHeight, 0, 'sent user message retained an empty spacer');
|
|
}
|
|
process.stdout.write(`${JSON.stringify({ browser, width, height, scrollAway, baseline: process.env.BASELINE_GEOMETRY === '1', result }, null, 2)}\n`);
|
|
} finally {
|
|
socket?.close();
|
|
if (child && child.exitCode === null) {
|
|
const closed = new Promise(resolveClose => child.once('close', resolveClose));
|
|
child.kill('SIGTERM');
|
|
await closed;
|
|
}
|
|
await server.close();
|
|
await rm(profile, { recursive: true, force: true });
|
|
}
|