Files

144 lines
9.4 KiB
JavaScript

const http = require('node:http');
const { readFile } = require('node:fs/promises');
const { resolve, extname } = require('node:path');
const assert = require('node:assert/strict');
const { chromium, expect } = require('@playwright/test');
const { WebSocketServer } = require('node:module').createRequire('/deps/package.json')('ws');
(async () => {
const root = '/work/terminal-dist';
const original = { id: 'fixture-original', name: 'Synthetic workspace', workspace: '/fixture', state: 'running', updated_at: 1 };
let sessions = [original], createCount = 0, delayLists = false, delayCreates = false;
let snapshot = 'synthetic-terminal-marker\n$ ';
const pendingLists = [], pendingCreates = [], attachments = [], inputs = [], resizes = [];
const json = (res, data) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(data)); };
const server = http.createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://fixture').pathname;
if (pathname === '/api/terminal/sessions') {
if (req.method === 'GET') {
if (delayLists) pendingLists.push(res); else json(res, sessions);
} else if (req.method === 'POST') {
createCount++;
const session = { ...original, id: 'fixture-created-' + createCount, name: 'Created synthetic workspace' };
sessions = [session, ...sessions];
if (delayCreates) pendingCreates.push({ res, session }); else json(res, session);
} else { res.writeHead(405); res.end(); }
return;
}
const file = resolve(root, pathname === '/' ? 'index.html' : '.' + pathname);
if (!file.startsWith(root + '/')) { res.writeHead(403); res.end(); return; }
try { res.setHeader('content-type', { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' }[extname(file)] || 'application/octet-stream'); res.end(await readFile(file)); }
catch { res.writeHead(404); res.end(); }
});
const ws = new WebSocketServer({ server, path: '/ws/terminal' });
ws.on('connection', (socket, request) => {
const id = new URL(request.url, 'http://fixture').searchParams.get('session');
attachments.push(id);
socket.send(JSON.stringify({ type: 'output', data: snapshot }));
socket.on('message', data => {
const packet = JSON.parse(String(data));
if (packet.type === 'input') inputs.push(packet.data);
if (packet.type === 'resize') resizes.push(packet);
});
});
await new Promise(done => server.listen(0, '127.0.0.1', done));
let browser;
try {
browser = await chromium.launch({ executablePath: process.env.CHROMIUM_EXECUTABLE, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const origin = `http://127.0.0.1:${server.address().port}`;
const page = await browser.newPage();
const errors = []; page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
for (const width of [1440, 390]) {
sessions = [original];
snapshot = 'synthetic-terminal-marker\n$ ';
await page.setViewportSize({ width, height: 844 });
await page.goto(origin);
const before = attachments.length, createsBefore = createCount;
await page.getByRole('button', { name: 'Open terminal', exact: true }).click();
await expect.poll(() => attachments.length).toBe(before + 1);
await expect.poll(() => page.evaluate(() => {
const buffer = window.fixtureTerminal?.buffer.active;
return buffer ? Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)?.translateToString()).join('\n') : '';
})).toContain('synthetic-terminal-marker');
await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.type('fixture-input');
await expect.poll(() => inputs.join('')).toContain('fixture-input');
await expect.poll(() => resizes.length).toBeGreaterThan(0);
assert.ok(resizes.every(value => value.cols > 0 && value.rows > 0));
const bufferText = () => page.evaluate(() => {
const buffer = window.fixtureTerminal.buffer.active;
return Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)?.translateToString()).join('\n');
});
snapshot = ['synthetic-terminal-marker', ...Array.from({ length: 100 }, (_, i) => `HISTORY-${String(i).padStart(4, '0')}`), '$ '].join('\n');
for (const socket of ws.clients) socket.send(JSON.stringify({ type: 'output', data: snapshot }));
await expect.poll(bufferText).toContain('HISTORY-0099');
assert.ok(await page.evaluate(() => window.fixtureTerminal.buffer.active.baseY > 0), 'History must extend beyond the viewport');
snapshot += '\nSNAPSHOT-REPLACEMENT';
for (const socket of ws.clients) socket.send(JSON.stringify({ type: 'output', data: snapshot }));
await expect.poll(bufferText).toContain('SNAPSHOT-REPLACEMENT');
assert.equal((await bufferText()).split('HISTORY-0000').length - 1, 1, 'Replacing snapshots must preserve history exactly once');
for (const socket of ws.clients) socket.terminate();
await expect.poll(() => attachments.length, { timeout: 5000 }).toBe(before + 2);
assert.deepEqual(attachments.slice(before), [original.id, original.id]);
assert.equal(createCount, createsBefore, 'Reconnect must not create another session');
await expect.poll(bufferText).toContain('HISTORY-0000');
assert.equal((await bufferText()).split('HISTORY-0000').length - 1, 1, 'Reconnect must reconstruct history exactly once');
await page.evaluate(() => window.fixtureTerminal.scrollToTop());
await expect.poll(() => page.evaluate(() => window.fixtureTerminal.buffer.active.viewportY)).toBe(0);
await expect(page.locator('.xterm-rows')).toContainText('HISTORY-0000');
await page.screenshot({ path: `/out/terminal-history-${width}.png` });
await page.getByRole('button', { name: 'Close', exact: true }).click();
await expect.poll(() => ws.clients.size).toBe(0);
delayLists = true;
await page.getByRole('button', { name: 'Open terminal', exact: true }).click();
await expect.poll(() => pendingLists.length).toBe(1);
await page.getByRole('button', { name: 'Close', exact: true }).click();
const listResponse = page.waitForResponse(response => response.url().endsWith('/api/terminal/sessions'));
json(pendingLists.shift(), sessions); await listResponse;
delayLists = false;
await page.waitForTimeout(150);
assert.equal(attachments.length, before + 2, 'Late inventory must not attach after close');
await page.getByRole('button', { name: 'Open terminal', exact: true }).click();
await expect.poll(() => attachments.length).toBe(before + 3);
delayCreates = true;
const newButton = page.getByRole('button', { name: 'New', exact: true });
await newButton.click();
await expect(newButton).toBeDisabled();
await newButton.evaluate(button => { button.click(); button.click(); });
await expect.poll(() => pendingCreates.length).toBe(1);
assert.equal(createCount, createsBefore + 1);
await page.getByRole('button', { name: 'Close', exact: true }).click();
const createResponse = page.waitForResponse(response => response.request().method() === 'POST');
const pending = pendingCreates.shift(); json(pending.res, pending.session); await createResponse;
delayCreates = false;
await page.waitForTimeout(150);
assert.equal(attachments.length, before + 3, 'Late creation must not attach after close');
await page.getByRole('button', { name: 'Open terminal', exact: true }).click();
await expect.poll(() => attachments.length).toBe(before + 4);
await expect(page.getByRole('combobox', { name: 'Terminal session' }).locator('option')).toHaveCount(3);
assert.equal(createCount, createsBefore + 1, 'Recover actual created session by listing, never replay POST');
await page.screenshot({ path: `/out/terminal-${width}.png` });
await expect(page.locator('[data-archipelago-terminal] .xterm')).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const buffer = window.fixtureTerminal?.buffer.active;
return buffer ? Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)?.translateToString()).join('\n') : '';
})).toContain('synthetic-terminal-marker');
const panel = await page.locator('[data-archipelago-terminal]').boundingBox();
assert.ok(panel && panel.width <= width && panel.x >= 0 && panel.x + panel.width <= width, `Terminal fits ${width}px viewport: ${JSON.stringify(panel)}`);
await page.getByRole('button', { name: 'Close', exact: true }).click();
await expect.poll(() => ws.clients.size).toBe(0);
}
assert.deepEqual(errors, []);
console.log('PASS actual xterm scrollback/snapshot replacement/reconnect without duplicate history, visible scrolled renderer; buffer/input/resize; actual HTTP+WebSocket same-session reconnect; no duplicate creation or late offscreen attachment; created-session recovery and1440/390 layout. Synthetic service only, no shell/installed tmux/device acceptance.');
} finally {
if (browser) await browser.close();
for (const res of pendingLists) res.destroy();
for (const item of pendingCreates) item.res.destroy();
for (const socket of ws.clients) socket.terminate();
await new Promise(done => ws.close(done));
await new Promise(done => server.close(done));
}
})().catch(error => { console.error(error); process.exitCode = 1; });