Files
companion-buttons/test/companion.test.js
T
christianandClaude Opus 5.5 0a6cec88aa Companion Buttons 1.1.0: pages in the + picker, buttons chosen on the tile
The picker row (page list, Clear, Done) is gone. Each Companion page is now
its own + entry, titled with the page's name, showing the whole page minus
page up/down. The grid icon switches the tile to choosing: click buttons to
take them out or put them back, click the icon again when done. The gear
holds one switch, Page layout (Companion's rows and columns, or packed).

- Tiles follow their page by Companion's page id and buttons by row/column,
  so inserted pages and re-imported pages keep showing.
- A tile remounted after a settings change asks GET /state for the snapshot
  the already-open stream sent, instead of waiting blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:48:24 -04:00

328 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});