'use strict'; /** * Round-trips a layout through the server's layouts API and checks that * nothing is lost on the way: a fully-populated tile, a tile field the server * has never heard of, the one-back copy a save leaves behind, restore, the * tile cap and delete. * * node tools/layout-roundtrip.test.js * * Plain Node, no dependencies. Starts server.js on a spare port with a * temporary data directory, talks to it over HTTP, exits non-zero on the * first failure and always stops the server it started. * * It exists because of one bug: for seventeen days the server rebuilt each * saved tile from the fields it knew and dropped the rest — including * `variant`, so every solo timer saved to a booth came back as "All timers". * A field added on the client must survive a server that has not heard of * it, and this is where that is checked. */ const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const fs = require('node:fs'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); function freePort() { return new Promise((resolve, reject) => { const probe = net.createServer(); probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); probe.on('error', reject); }); } async function waitFor(url, ms = 15000) { const deadline = Date.now() + ms; let last = ''; while (Date.now() < deadline) { try { const r = await fetch(url); if (r.ok) return; last = 'HTTP ' + r.status; } catch (err) { last = err.message; } await new Promise((r) => setTimeout(r, 200)); } throw new Error('server did not come up: ' + last); } (async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-')); const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { cwd: ROOT, env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); let log = ''; child.stdout.on('data', (d) => { log += d; }); child.stderr.on('data', (d) => { log += d; }); const base = `http://127.0.0.1:${port}`; const api = async (p, init = {}) => { const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init }); return { status: r.status, body: await r.json().catch(() => ({})) }; }; const layoutsDir = path.join(dataDir, 'layouts'); let failed = false; try { await waitFor(base + '/api/theme'); // Every field the shell writes today, one it will write tomorrow, and one // it has stopped writing. const tile = { id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2, settings: { showNames: false, __tint: '#112233' }, variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 }, futureField: 'kept', futureFlag: true, futureBox: { a: 1 }, headHidden: true, }; let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) }); assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body)); assert.equal(r.body.replaced, false, 'a first save replaces nothing'); r = await api('/api/layouts/Booth%20A'); assert.equal(r.status, 200); const got = r.body.layout; assert.equal(got.v, 2, 'the file carries a schema version'); assert.equal(got.autoHideHeads, true); const t = got.tiles[0]; for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k); assert.deepEqual(t.settings, tile.settings, 'settings'); assert.deepEqual(t.minSize, tile.minSize, 'minSize'); assert.equal(t.futureField, 'kept', 'an unknown string field survives'); assert.equal(t.futureFlag, true, 'an unknown boolean survives'); assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives'); assert.equal('headHidden' in t, false, 'a retired field is dropped'); // Saving over the name keeps the copy it replaced. r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) }); assert.equal(r.status, 200); assert.equal(r.body.replaced, true, 'the second save reports a replacement'); assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept'); r = await api('/api/layouts'); assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]], 'the listing shows one layout with a previous copy — not two layouts'); // Restore swaps them, so a second restore undoes the first. Admin is open // here (no PIN), which is what a fresh data directory gives. r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' }); assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body)); r = await api('/api/layouts/Booth%20A'); assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back'); r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' }); assert.equal(r.status, 200); r = await api('/api/layouts/Booth%20A'); assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again'); r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' }); assert.equal(r.status, 404, 'nothing to restore is said, not faked'); // Too many tiles is refused, not trimmed. const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i })); r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) }); assert.equal(r.status, 400); assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap'); assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written'); // Delete takes the previous copy with it. r = await api('/api/layouts/Booth%20A', { method: 'DELETE' }); assert.equal(r.status, 200); assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false); assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too'); r = await api('/api/layouts'); assert.deepEqual(r.body.layouts, [], 'nothing left listed'); console.log('layout round-trip: every check passed'); } catch (err) { failed = true; console.error('FAIL:', err.message); console.error('--- last of the server log ---\n' + log.slice(-2000)); } finally { child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 400)); fs.rmSync(dataDir, { recursive: true, force: true }); } process.exit(failed ? 1 : 0); })();