The bug behind this: for seventeen days the server rebuilt each saved tile from the fields it knew and dropped the rest — including `variant`, so a layout of five different timers came back from the server as five "All timers". The client had carried `variant` since multi-tile modules arrived; the server was the one that lagged, and it is the part of ProdDash that always will (a booth updates weeks after the dev machine). So the server stops deciding which tile fields exist: it checks the shape of the ones it knows, keeps any other small plain-JSON field a tile carries, drops only retired ones, stamps the file `v: 2`, and refuses a layout over a hundred tiles instead of quietly saving the first hundred. A test round-trips a fully-populated tile plus one the server has never heard of, and fails if anything is lost. Then the rule the arrangement is loaded by. A layout keeps every tile it is given; the board shows the ones whose module is installed and enabled and — for a tile added from a picker entry — whose entry is offered right now. The rest keep their place and draw nothing, and appear the moment the registry offers them again: a Service timer waits for PCO Plan, a transcript for its channel. Never the module's default in their place. Because a module's entry list can change with no event to mark it (PCO Plan comes back and, seconds later, the timers module is offering its timers again), the registry is re-read every few seconds while anything is folded away, and not at all otherwise. The timers module's placeholder card for a missing solo timer goes with this: nothing is drawn, the dot says why. Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service, #ops aren't offered right now" — with Undo in the message. Saving over a name asks first, and the server keeps the copy it replaced one back; Admin → Saved layouts gets Restore previous, which swaps the two so a restore is one restore from undone. The browser keeps one good copy of its own layout behind the live one and falls back to it when the live one will not parse. Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines had their variants stripped before this and have to be saved again from a browser that still holds the right tiles — nothing here recovers what the files no longer contain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
158 lines
6.9 KiB
JavaScript
158 lines
6.9 KiB
JavaScript
'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);
|
|
})();
|