2026-09-20 12:02:34 -04:00
|
|
|
'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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-22 10:19:49 -04:00
|
|
|
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
|
2026-09-20 12:02:34 -04:00
|
|
|
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);
|
2026-09-22 10:19:49 -04:00
|
|
|
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
|
2026-09-20 12:02:34 -04:00
|
|
|
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.
|
2026-09-22 10:19:49 -04:00
|
|
|
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
2026-09-20 12:02:34 -04:00
|
|
|
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');
|
2026-09-22 10:19:49 -04:00
|
|
|
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
|
2026-09-20 12:02:34 -04:00
|
|
|
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);
|
|
|
|
|
})();
|