'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 `button` events. */ function listen(port, path) { const events = []; 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 && name[1] === 'button') events.push(JSON.parse(data[1])); } }); resolve(); }); }); return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] }; } /* ── 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/); // 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' }, ]); // 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); // 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; await sleep(1600); 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' }); // a status button that changes is forwarded 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); // 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); // 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); // 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(); // the button moves to another page in Companion: the tile follows it mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [ { id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] }, { id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] }, ], }); await until('moved location', () => steady.last().location && steady.last().location.page === 2); assert.strictEqual(steady.last().controlId, 'bank:steady'); // 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(); // 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 steady.close(); flip.close(); gone.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/); 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'); await s.ready; await until('stream answers', () => s.last()); assert.strictEqual(s.last().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); });