144 lines
9.4 KiB
JavaScript
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; });
|