2026-10-14 20:12:23 -04:00
|
|
|
'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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
/** An SSE client that collects `button` events. */
|
2026-10-14 20:12:23 -04:00
|
|
|
function listen(port, path) {
|
2026-10-15 12:09:31 -04:00
|
|
|
const events = [];
|
2026-10-14 20:12:23 -04:00
|
|
|
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);
|
2026-10-15 12:09:31 -04:00
|
|
|
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
|
2026-10-14 20:12:23 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-15 12:09:31 -04:00
|
|
|
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
|
2026-10-14 20:12:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── 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/);
|
|
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// pages for the picker
|
|
|
|
|
const pages = JSON.parse((await get(at, '/pages')).body);
|
|
|
|
|
assert.strictEqual(pages.status, 'ok');
|
|
|
|
|
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
|
|
|
|
|
assert.deepStrictEqual(pages.pages[1].buttons, [
|
|
|
|
|
{ row: 0, column: 0, controlId: 'bank:cam1' },
|
|
|
|
|
{ row: 1, column: 3, controlId: 'bank:cam2' },
|
2026-10-14 20:12:23 -04:00
|
|
|
]);
|
|
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// thumbnails
|
|
|
|
|
const img = await get(at, '/image/blue');
|
|
|
|
|
assert.strictEqual(img.status, 200);
|
|
|
|
|
assert.strictEqual(img.type, 'image/png');
|
|
|
|
|
assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]);
|
|
|
|
|
assert.strictEqual((await get(at, '/image/gone')).status, 404);
|
|
|
|
|
assert.strictEqual((await get(at, '/image/..%2Fetc')).status, 400);
|
|
|
|
|
assert.strictEqual((await get(at, `/image/${'x'.repeat(200)}`)).status, 400);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// the gear's Page list, by Companion's page id
|
|
|
|
|
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
|
|
|
|
|
assert.deepStrictEqual(pageOptions, [
|
|
|
|
|
{ value: 'p1', label: '1 Status' },
|
|
|
|
|
{ value: 'p2', label: '2 Cameras' },
|
|
|
|
|
{ value: 'p3', label: 'Page 3' },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// a tile that joins a stream its dashboard already has open asks for the snapshot instead
|
|
|
|
|
const warm = listen(at, '/stream/1.0.2.blue');
|
|
|
|
|
await warm.ready;
|
|
|
|
|
await until('blue streamed', () => warm.last() && warm.last().image);
|
|
|
|
|
const snap = JSON.parse((await get(at, '/state/1.0.2.blue/2.0.0.oldid/3.0.0.gone')).body).buttons;
|
|
|
|
|
assert.deepStrictEqual(snap.map((b) => [b.key, b.controlId, Boolean(b.image), b.seen]), [
|
|
|
|
|
['1.0.2.blue', 'bank:blue', true, true],
|
|
|
|
|
['2.0.0.oldid', 'bank:cam1', false, false],
|
|
|
|
|
['3.0.0.gone', null, false, true],
|
|
|
|
|
]);
|
|
|
|
|
assert.strictEqual(snap[0].image, warm.last().image);
|
|
|
|
|
assert.deepStrictEqual(snap[0].location, { page: 1, row: 0, column: 2, pageName: '1 Status' });
|
|
|
|
|
assert.strictEqual((await get(at, '/state/blue')).status, 400);
|
|
|
|
|
warm.close();
|
|
|
|
|
|
|
|
|
|
// a button that redraws the same picture every 300 ms sends one event, not five
|
|
|
|
|
const steady = listen(at, '/stream/1.0.1.steady');
|
|
|
|
|
await steady.ready;
|
|
|
|
|
await until('steady first frame', () => steady.last() && steady.last().image);
|
|
|
|
|
const settled = steady.events.length;
|
2026-10-14 20:12:23 -04:00
|
|
|
await sleep(1600);
|
2026-10-15 12:09:31 -04:00
|
|
|
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent');
|
|
|
|
|
assert.strictEqual(steady.last().key, '1.0.1.steady');
|
|
|
|
|
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
|
|
|
|
assert.deepStrictEqual(steady.last().location, { page: 1, row: 0, column: 1, pageName: '1 Status' });
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
// a status button that changes is forwarded
|
2026-10-15 12:09:31 -04:00
|
|
|
const flip = listen(at, '/stream/1.0.0.flip');
|
|
|
|
|
await flip.ready;
|
|
|
|
|
await until('flip first frame', () => flip.last() && flip.last().image);
|
|
|
|
|
const firstLook = flip.last().image;
|
|
|
|
|
await until('flip redraw', () => flip.last().image !== firstLook, 4000);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// one stream carries all of a tile's buttons: each is sent on connect
|
|
|
|
|
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
|
|
|
|
|
await pair.ready;
|
|
|
|
|
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
|
|
|
|
|
assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']);
|
|
|
|
|
pair.close();
|
|
|
|
|
assert.strictEqual((await get(at, `/stream/${Array.from({ length: 65 }, (_, i) => `1.0.${i}.b${i}`).join('/')}`)).status, 400);
|
|
|
|
|
assert.strictEqual((await get(at, '/stream/flip')).status, 400);
|
|
|
|
|
assert.strictEqual((await get(at, '/stream/1.0.0.bank:flip')).status, 400);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// a button that is gone, with nothing at its place either, says so instead of hanging
|
|
|
|
|
const gone = listen(at, '/stream/3.0.0.gone');
|
|
|
|
|
await gone.ready;
|
|
|
|
|
await until('missing button answered', () => gone.last() && gone.last().seen);
|
|
|
|
|
assert.strictEqual(gone.last().image, null);
|
|
|
|
|
assert.strictEqual(gone.last().controlId, null);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// an unknown id falls back to what is at its page/row/column
|
|
|
|
|
const byPlace = listen(at, '/stream/2.0.0.oldid');
|
|
|
|
|
await byPlace.ready;
|
|
|
|
|
await until('found by place', () => byPlace.last() && byPlace.last().image);
|
|
|
|
|
assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
|
|
|
|
|
byPlace.close();
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// the button moves to another page in Companion: the tile follows it
|
2026-10-14 20:12:23 -04:00
|
|
|
mock.pageUpdate({
|
|
|
|
|
type: 'update',
|
|
|
|
|
updatedOrder: null,
|
|
|
|
|
added: [],
|
2026-10-15 12:09:31 -04:00
|
|
|
changes: [
|
|
|
|
|
{ id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
|
|
|
|
|
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] },
|
|
|
|
|
],
|
2026-10-14 20:12:23 -04:00
|
|
|
});
|
2026-10-15 12:09:31 -04:00
|
|
|
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
|
|
|
|
|
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
2026-10-14 20:12:23 -04:00
|
|
|
|
2026-10-15 12:09:31 -04:00
|
|
|
// the page is re-imported: every button gets a new id, the tile keeps its place
|
|
|
|
|
mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
|
|
|
|
|
const blue = listen(at, '/stream/1.0.2.blue');
|
|
|
|
|
await blue.ready;
|
|
|
|
|
await until('blue', () => blue.last() && blue.last().image);
|
|
|
|
|
mock.pageUpdate({
|
|
|
|
|
type: 'update',
|
|
|
|
|
updatedOrder: null,
|
|
|
|
|
added: [],
|
|
|
|
|
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
|
|
|
|
|
});
|
|
|
|
|
await until('re-imported button found by place', () => blue.last().controlId === 'bank:blue-new' && blue.last().image);
|
|
|
|
|
blue.close();
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
// 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
|
2026-10-15 12:09:31 -04:00
|
|
|
steady.close();
|
|
|
|
|
flip.close();
|
|
|
|
|
gone.close();
|
2026-10-14 20:12:23 -04:00
|
|
|
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/);
|
2026-10-15 12:09:31 -04:00
|
|
|
const pages = JSON.parse((await get(at, '/pages')).body);
|
|
|
|
|
assert.strictEqual(pages.status, 'error');
|
|
|
|
|
assert.deepStrictEqual(pages.pages, []);
|
|
|
|
|
assert.strictEqual((await get(at, '/image/x')).status, 503);
|
|
|
|
|
const s = listen(at, '/stream/1.0.0.x');
|
2026-10-14 20:12:23 -04:00
|
|
|
await s.ready;
|
2026-10-15 12:09:31 -04:00
|
|
|
await until('stream answers', () => s.last());
|
|
|
|
|
assert.strictEqual(s.last().status, 'error');
|
2026-10-14 20:12:23 -04:00
|
|
|
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);
|
|
|
|
|
});
|