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