130 lines
8.9 KiB
JavaScript
130 lines
8.9 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 { tmpdir } from 'node:os';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const browser = process.env.CHROME_EXECUTABLE || '/home/buzzert/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
|
|
const profile = await mkdtemp(`${tmpdir()}/sybil-agent-composer-layout-`);
|
|
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, socket;
|
|
try {
|
|
await server.listen();
|
|
const address = server.httpServer.address();
|
|
const port = await new Promise((resolvePort, reject) => {
|
|
child = spawn(browser, ['--headless', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu',
|
|
'--remote-debugging-port=0', '--remote-allow-origins=*', `--user-data-dir=${profile}`, '--no-first-run']);
|
|
let diagnostics = '';
|
|
const timeout = setTimeout(() => reject(new Error(`Chrome startup failed: ${diagnostics.slice(-500)}`)), 10000);
|
|
child.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])); }
|
|
});
|
|
child.once('error', error => { clearTimeout(timeout); reject(error); });
|
|
child.once('close', code => { clearTimeout(timeout); reject(new Error(`Chrome exited (${code})`)); });
|
|
});
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/new?http://127.0.0.1:${address.port}/tests/agent-composer-layout-fixture.html`, { method: 'PUT' });
|
|
assert.equal(response.status, 200);
|
|
const target = await response.json();
|
|
socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
await new Promise((resolveOpen, reject) => { socket.addEventListener('open', resolveOpen, { once: true }); socket.addEventListener('error', reject, { once: true }); });
|
|
let id = 0;
|
|
const pending = new Map();
|
|
socket.addEventListener('message', event => {
|
|
const message = JSON.parse(event.data), request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
|
|
});
|
|
const call = (method, params = {}) => new Promise((resolveCall, reject) => {
|
|
const requestId = ++id; pending.set(requestId, { resolve: resolveCall, reject });
|
|
socket.send(JSON.stringify({ id: requestId, method, params }));
|
|
});
|
|
await call('Page.enable'); await call('Runtime.enable');
|
|
await call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
|
window.__layoutTrace = [];
|
|
const Native = window.ResizeObserver;
|
|
window.ResizeObserver = class extends Native {
|
|
constructor(callback) { super((entries, observer) => {
|
|
const time = performance.now();
|
|
for (const entry of entries) window.__layoutTrace.push({ kind: 'resize', time,
|
|
target: entry.target.className || entry.target.tagName,
|
|
height: entry.contentRect.height, clientHeight: entry.target.clientHeight,
|
|
scrollHeight: entry.target.scrollHeight, scrollTop: entry.target.scrollTop });
|
|
callback(entries, observer);
|
|
}); }
|
|
};
|
|
document.addEventListener('scroll', event => {
|
|
const el = event.target;
|
|
if (el instanceof HTMLElement && el.classList.contains('agent-room__scroll')) window.__layoutTrace.push({ kind: 'scroll', time: performance.now(),
|
|
clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, scrollTop: el.scrollTop,
|
|
gap: el.scrollHeight - el.scrollTop - el.clientHeight });
|
|
}, true);
|
|
})()` });
|
|
const configurations = [{ width: 1200, height: 900, dpr: 1 }, { width: 803, height: 777, dpr: 1.25 }, { width: 389, height: 844, dpr: 2 }];
|
|
const all = [];
|
|
for (const config of configurations) {
|
|
await call('Emulation.setDeviceMetricsOverride', { width: config.width, height: config.height, deviceScaleFactor: config.dpr, mobile: config.width < 768 });
|
|
await call('Page.navigate', { url: `http://127.0.0.1:${address.port}/tests/agent-composer-layout-fixture.html?case=${config.width}` });
|
|
const result = await call('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `new Promise(resolve => {
|
|
const wait = () => {
|
|
const scroller = document.querySelector('.agent-room__scroll'), textarea = document.querySelector('textarea[aria-label="Message Sybil Agent"]');
|
|
if (!scroller || !textarea) return setTimeout(wait, 20);
|
|
setTimeout(async () => {
|
|
const settle = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 90))));
|
|
const gap = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
|
const visibleAnchor = () => [...scroller.querySelectorAll('[data-agent-entry-id]')].find(row => row.getBoundingClientRect().bottom > scroller.getBoundingClientRect().top + 8);
|
|
const sample = label => { const row = visibleAnchor(); return { label, top: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight,
|
|
gap: gap(), textareaHeight: textarea.getBoundingClientRect().height, textareaScrollHeight: textarea.scrollHeight,
|
|
anchorId: row?.dataset.agentEntryId, anchorTop: row?.getBoundingClientRect().top, dpr: devicePixelRatio } };
|
|
const setValue = async (value, label) => { textarea.value = value; textarea.dispatchEvent(new Event('input', { bubbles: true })); await settle(); return sample(label); };
|
|
const result = { config: ${JSON.stringify(config)}, bottom: [], nonbottom: [] };
|
|
scroller.scrollTop = scroller.scrollHeight; scroller.dispatchEvent(new Event('scroll')); await settle();
|
|
result.bottom.push(sample('empty'));
|
|
result.bottom.push(await setValue('Short draft', 'short'));
|
|
result.bottom.push(await setValue(Array.from({length: 8}, (_,i) => 'Line '+(i+1)).join('\\n'), 'eight-lines'));
|
|
result.bottom.push(await setValue(Array.from({length: 26}, (_,i) => 'Wrapped composer line '+(i+1)+' with enough words to wrap at this viewport width.').join('\\n'), 'wrapped-and-capped'));
|
|
result.bottom.push(await setValue('one line again', 'shrink'));
|
|
result.bottom.push(await setValue('', 'cleared'));
|
|
scroller.scrollTop = Math.max(0, (scroller.scrollHeight - scroller.clientHeight) * 0.55); scroller.dispatchEvent(new Event('scroll')); await settle();
|
|
const before = sample('before-nonbottom');
|
|
result.nonbottom.push(await setValue(Array.from({length: 7}, (_,i) => 'Reader draft '+i).join('\\n'), 'nonbottom-grow'));
|
|
result.nonbottom.push(await setValue('shrunk reader draft', 'nonbottom-shrink'));
|
|
result.nonbottom.push({ before });
|
|
result.trace = (window).__layoutTrace;
|
|
resolve(result);
|
|
}, 500);
|
|
}; wait();
|
|
})` });
|
|
assert.equal(result.exceptionDetails, undefined, result.exceptionDetails?.text);
|
|
all.push(result.result.value);
|
|
}
|
|
if (process.env.RECORD_ONLY !== '1') for (const result of all) {
|
|
for (const sample of result.bottom) assert.ok(Math.abs(sample.gap) <= 1,
|
|
`bottom pin drift at ${result.config.width}px/${result.config.dpr}x: ${JSON.stringify(sample)}`);
|
|
const byLabel = Object.fromEntries(result.bottom.map(sample => [sample.label, sample]));
|
|
assert.equal(byLabel['wrapped-and-capped'].textareaHeight, 156, `textarea cap changed: ${JSON.stringify(result.bottom)}`);
|
|
assert.ok(byLabel.shrink.textareaHeight <= 48 && byLabel.cleared.textareaHeight <= 48,
|
|
`textarea did not shrink with the draft: ${JSON.stringify(result.bottom)}`);
|
|
assert.equal(byLabel.cleared.clientHeight, byLabel.empty.clientHeight,
|
|
`composer did not return to its empty height: ${JSON.stringify(result.bottom)}`);
|
|
const before = result.nonbottom.find(sample => sample.before)?.before;
|
|
for (const sample of result.nonbottom.filter(sample => sample.label?.startsWith('nonbottom-'))) {
|
|
assert.equal(sample.anchorId, before.anchorId, `nonbottom anchor changed: ${JSON.stringify({ before, sample })}`);
|
|
assert.ok(Math.abs(sample.anchorTop - before.anchorTop) <= 1, `nonbottom transcript jumped: ${JSON.stringify({ before, sample })}`);
|
|
}
|
|
const nonbottomShrink = result.nonbottom.find(sample => sample.label === 'nonbottom-shrink');
|
|
assert.ok(nonbottomShrink.textareaHeight <= 48, `nonbottom textarea did not shrink: ${JSON.stringify(nonbottomShrink)}`);
|
|
}
|
|
process.stdout.write(JSON.stringify({ browser, all }, null, 2) + '\n');
|
|
} finally {
|
|
socket?.close();
|
|
if (child && child.exitCode === null) { const closed = new Promise(done => child.once('close', done)); child.kill('SIGTERM'); await closed; }
|
|
await server.close(); await rm(profile, { recursive: true, force: true });
|
|
}
|