Files
companion-buttons/test/companion.test.js
T

328 lines
14 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* Tests for Companion Buttons.
*
* node test/companion.test.js
*
* Plain Node (22 or newer, for the built-in WebSocket), no dependencies;
* exits non-zero on the first failure. The server entry runs against
* test/companion-mock.js, mounted the way ProdDash mounts it, so the routes,
* the reconnect after Companion drops, and the de-duplication of identical
* redraws are all exercised over real sockets.
*/
const assert = require('assert');
const http = require('http');
const { createPageBook, pageName } = require('../companion');
const serverEntry = require('../server');
const { startMock } = require('./companion-mock');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(what, fn, ms = 5000) {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await sleep(50);
}
}
/* ── ProdDash's route mounting, reduced to what this module uses ──────── */
function mount(table) {
const routes = Object.entries(table).map(([key, fn]) => {
const [method, path] = key.split(' ');
return { method, prefix: path.endsWith('/*') ? path.slice(0, -2) : null, path, fn };
});
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
for (const r of routes) {
if (r.method !== '*' && r.method !== req.method) continue;
if (r.prefix !== null ? url.pathname === r.prefix || url.pathname.startsWith(`${r.prefix}/`) : url.pathname === r.path) {
req.wildcard = r.prefix !== null ? url.pathname.slice(r.prefix.length) : '';
req.search = url.search;
return r.fn(req, res);
}
}
res.writeHead(404);
res.end();
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
}
function get(port, path) {
return new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port, path }, (res) => {
const parts = [];
res.on('data', (d) => parts.push(d));
res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body: Buffer.concat(parts) }));
}).on('error', reject);
});
}
/** An SSE client that collects `page` and `button` events. */
function listen(port, path) {
const pages = [];
const buttons = [];
let req;
const ready = new Promise((resolve) => {
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
let buf = '';
res.on('data', (d) => {
buf += d.toString('utf8');
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, at);
buf = buf.slice(at + 2);
const name = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (!name || !data) continue;
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
}
});
resolve();
});
});
return {
pages,
buttons,
ready,
close: () => req.destroy(),
page: () => pages[pages.length - 1],
/** The latest picture for one row.column, or undefined. */
pic: (key) => [...buttons].reverse().find((b) => b.key === key),
};
}
/* ── pure parts ────────────────────────────────────────────────────────── */
function testPageName() {
assert.strictEqual(pageName('19\\nStatus', 19), '19 Status');
assert.strictEqual(pageName('7\\nStudio\\nB', 7), '7 Studio B');
assert.strictEqual(pageName('PAGE', 24), 'Page 24');
assert.strictEqual(pageName('', 3), 'Page 3');
assert.strictEqual(pageName('Cameras', 2), '2 Cameras');
assert.strictEqual(pageName('2', 2), '2');
assert.strictEqual(pageName('20 Restart', 20), '20 Restart');
}
function testPageBook() {
const book = createPageBook();
assert.strictEqual(book.loaded, false);
book.apply({
type: 'init',
order: ['a', 'b'],
pages: {
a: { id: 'a', name: 'One', controls: { 0: { 0: 'bank:x', 1: 'bank:y' } } },
b: { id: 'b', name: 'PAGE', controls: {} },
},
});
assert.deepStrictEqual(book.locate('bank:y'), { page: 1, row: 0, column: 1, pageName: '1 One' });
assert.strictEqual(book.locate('bank:none'), null);
assert.deepStrictEqual(book.list().map((p) => [p.number, p.name, p.buttons.length]), [[1, '1 One', 2], [2, 'Page 2', 0]]);
// a button moves to page 2, page 2 is named
book.apply({
type: 'update',
updatedOrder: null,
added: [],
changes: [
{ id: 'a', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
{ id: 'b', name: 'Two', controls: [{ row: 2, column: 3, controlId: 'bank:y' }] },
],
});
assert.deepStrictEqual(book.locate('bank:y'), { page: 2, row: 2, column: 3, pageName: '2 Two' });
// a page inserted in front renumbers the rest, as Companion does
book.apply({ type: 'update', updatedOrder: ['c', 'a', 'b'], added: [{ id: 'c', name: 'New', controls: {} }], changes: [] });
assert.strictEqual(book.locate('bank:y').page, 3);
assert.strictEqual(book.locate('bank:x').page, 2);
// a page removed
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
assert.strictEqual(book.locate('bank:x'), null);
assert.strictEqual(book.count, 2);
assert.strictEqual(book.page('b').number, 2);
assert.strictEqual(book.page('a'), null);
}
/* ── against the mock ─────────────────────────────────────────────────── */
async function testLive() {
let mock = await startMock(0);
const port = mock.port;
const logs = [];
const config = { companion: { host: '127.0.0.1', port } };
const handle = serverEntry.init({ config, log: (...a) => logs.push(a.join(' ')) });
const app = await mount(serverEntry.routes({ config }));
const at = app.address().port;
try {
await until('Companion connected', () => handle.health().status === 'ok');
assert.match(handle.health().message, /3 pages/);
// the + picker: one entry per page with something to show — not the page that is only navigation
await until('nav buttons learned', () => serverEntry.tiles().length === 2);
const tiles = serverEntry.tiles();
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
assert.deepStrictEqual(pageOptions.map((o) => o.label), ['1 Status', '2 Cameras', 'Page 3']);
// a whole page: every button but the page-up one, laid out where Companion has them
const whole = listen(at, '/stream/p1');
await whole.ready;
await until('whole page', () => whole.page() && whole.page().status === 'ok' && whole.page().spots.length === 3);
assert.deepStrictEqual(whole.page().spots, [
{ key: '0.0', row: 0, column: 0 },
{ key: '0.1', row: 0, column: 1 },
{ key: '0.2', row: 0, column: 2 },
]);
assert.strictEqual(whole.page().name, '1 Status');
await until('three pictures', () => ['0.0', '0.1', '0.2'].every((k) => whole.pic(k) && whole.pic(k).image));
// the snapshot a remounted tile asks for matches what the stream sent
const snap = JSON.parse((await get(at, '/state/p1')).body);
assert.deepStrictEqual(snap.page.spots, whole.page().spots);
assert.deepStrictEqual(snap.buttons.map((b) => [b.key, Boolean(b.image), b.seen]), [['0.0', true, true], ['0.1', true, true], ['0.2', true, true]]);
assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
// a button that redraws the same picture every 300 ms is sent once, not five times
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
await sleep(1600);
assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent');
// a status button that changes is forwarded
const firstLook = whole.pic('0.0').image;
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
// chosen buttons only, in the order chosen
const some = listen(at, '/stream/p2/1.3/0.0');
await some.ready;
await until('two ticked', () => some.page() && some.page().spots.length === 2 && some.pic('1.3') && some.pic('1.3').image && some.pic('0.0'));
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
// a chosen place with nothing on it is answered, not left waiting
const hole = listen(at, '/stream/p2/3.3');
await hole.ready;
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
assert.strictEqual(hole.pic('3.3').image, null);
hole.close();
// a page that does not exist says so
const missing = listen(at, '/stream/nope');
await missing.ready;
await until('missing page', () => missing.page());
assert.strictEqual(missing.page().found, false);
missing.close();
// bad paths
assert.strictEqual((await get(at, '/stream/p1/abc')).status, 400);
assert.strictEqual((await get(at, '/stream/p:1')).status, 400);
assert.strictEqual((await get(at, `/stream/p1/${Array.from({ length: 65 }, (_, i) => `0.${i}`).join('/')}`)).status, 400);
// the page is re-imported: every button gets a new id, the tile keeps showing the same places
mock.looks['bank:cam2-new'] = mock.looks['bank:cam2'];
mock.configs['bank:cam2-new'] = mock.configs['bank:cam2'];
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
});
await until('renamed', () => some.page().name === '2 Cameras Renamed');
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
// a page inserted in front: the tile follows its page, now page 3
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
some.close();
// a button added to a whole-page tile appears in it
mock.looks['bank:new'] = mock.looks['bank:blue'];
mock.configs['bank:new'] = mock.configs['bank:blue'];
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image);
const flip = { last: () => ({ status: whole.page().status, image: whole.pic('0.0') && whole.pic('0.0').image }) };
// Companion drops: streams say so, then recover by themselves
mock.dropAll();
await until('error reported', () => flip.last().status === 'error');
assert.strictEqual(handle.health().status, 'error');
await until('reconnected', () => flip.last().status === 'ok' && handle.health().status === 'ok', 6000);
await until('pictures again', () => flip.last().image, 4000);
// Companion restarts on the same port (down for a moment, then back)
await mock.close();
await until('error while down', () => handle.health().status === 'error');
mock = await startMock(port);
await until('back after restart', () => handle.health().status === 'ok', 8000);
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
whole.close();
await sleep(100);
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
} finally {
handle.stop();
await until('Companion connection closed', () => mock.clients.size === 0, 2000).catch(() => {});
assert.strictEqual(mock.clients.size, 0, 'stop() must close the Companion connection');
await mock.close();
await new Promise((r) => app.close(r));
}
}
async function testUnconfigured() {
const handle = serverEntry.init({ config: { companion: { host: '', port: 8000 } }, log: () => {} });
const app = await mount(serverEntry.routes({}));
const at = app.address().port;
try {
assert.strictEqual(handle.health().status, 'error');
assert.match(handle.health().message, /admin/);
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
assert.deepStrictEqual(serverEntry.tiles(), []);
const s = listen(at, '/stream/p1');
await s.ready;
await until('stream answers', () => s.page());
assert.strictEqual(s.page().status, 'error');
s.close();
} finally {
handle.stop();
await new Promise((r) => app.close(r));
}
}
async function testNothingThere() {
// A port nobody listens on: the module reports it, keeps retrying, and stops cleanly.
const handle = serverEntry.init({ config: { companion: { host: '127.0.0.1', port: 9 } }, log: () => {} });
try {
await until('unreachable reported', () => handle.health().status === 'error');
assert.match(handle.health().message, /127\.0\.0\.1:9/);
} finally {
handle.stop();
}
}
(async () => {
if (typeof WebSocket !== 'function') {
console.error('These tests need Node 22 or newer (built-in WebSocket).');
process.exit(1);
}
const tests = [testPageName, testPageBook, testUnconfigured, testNothingThere, testLive];
for (const t of tests) {
await t();
console.log(`ok ${t.name}`);
}
console.log(`all ${tests.length} passed`);
process.exit(0);
})().catch((err) => {
console.error(err);
process.exit(1);
});