Companion Buttons 1.0.0: any Companion button from any page, live on a ProdDash tile
A ProdDash module for status buttons and what's happening: pick buttons from any Companion page in the tile's own picker; each is shown exactly as Companion draws it and redraws live. One tile can hold one button or many. - One read-only connection to Companion's tRPC websocket (pages.watch and preview.graphics.controlId), shared by every dashboard; identical redraws are not re-sent. - A tile follows its button by id, and by page/row/column when the id is gone (a re-imported page), saving back what it resolved to. - One SSE stream per tile, within ProdDash's 32-streams-per-page limit. - Red dot and a faded "Companion offline" view when Companion is down; recovers by itself. - Tests against a mock Companion: node test/companion.test.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A stand-in for Companion's tRPC websocket, so the module can be built and
|
||||
* tested without the real thing:
|
||||
*
|
||||
* node test/companion-mock.js 18000
|
||||
*
|
||||
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
|
||||
*
|
||||
* It serves the two subscriptions the module uses — pages.watch and
|
||||
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with
|
||||
* three pages. Page 1's first button flips between green and red every two
|
||||
* seconds, like a status button; page 1's second button redraws the same
|
||||
* picture every 300 ms, like a button fed a variable that has not changed.
|
||||
* Every 30 s it sends PING and drops a client that does not answer PONG,
|
||||
* the way Companion does.
|
||||
*
|
||||
* --rename-after <s> rename page 2 after s seconds (an `update` change)
|
||||
* --move-after <s> move the flipping button to page 2 after s seconds
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
|
||||
/* ── a solid-colour PNG, with a lighter band so pictures differ ─────── */
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function png([r, g, b], size = 72) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // RGB
|
||||
const rows = [];
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
const row = Buffer.alloc(1 + size * 3);
|
||||
const band = y > size * 0.4 && y < size * 0.6;
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
row[1 + x * 3] = band ? 255 : r;
|
||||
row[2 + x * 3] = band ? 255 : g;
|
||||
row[3 + x * 3] = band ? 255 : b;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const out = Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]);
|
||||
return `data:image/png;base64,${out.toString('base64')}`;
|
||||
}
|
||||
|
||||
/* ── the minimal websocket server (RFC 6455, text frames only) ────────── */
|
||||
|
||||
function frame(text) {
|
||||
const payload = Buffer.from(text, 'utf8');
|
||||
let head;
|
||||
if (payload.length < 126) {
|
||||
head = Buffer.from([0x81, payload.length]);
|
||||
} else if (payload.length < 65536) {
|
||||
head = Buffer.alloc(4);
|
||||
head[0] = 0x81;
|
||||
head[1] = 126;
|
||||
head.writeUInt16BE(payload.length, 2);
|
||||
} else {
|
||||
head = Buffer.alloc(10);
|
||||
head[0] = 0x81;
|
||||
head[1] = 127;
|
||||
head.writeBigUInt64BE(BigInt(payload.length), 2);
|
||||
}
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
function readFrames(state, data, onText, onClose) {
|
||||
state.buf = Buffer.concat([state.buf, data]);
|
||||
for (;;) {
|
||||
const b = state.buf;
|
||||
if (b.length < 2) return;
|
||||
const op = b[0] & 0x0f;
|
||||
let len = b[1] & 0x7f;
|
||||
let off = 2;
|
||||
if (len === 126) {
|
||||
if (b.length < 4) return;
|
||||
len = b.readUInt16BE(2);
|
||||
off = 4;
|
||||
} else if (len === 127) {
|
||||
if (b.length < 10) return;
|
||||
len = Number(b.readBigUInt64BE(2));
|
||||
off = 10;
|
||||
}
|
||||
const masked = (b[1] & 0x80) !== 0;
|
||||
const mask = masked ? b.subarray(off, off + 4) : null;
|
||||
if (masked) off += 4;
|
||||
if (b.length < off + len) return;
|
||||
const payload = Buffer.from(b.subarray(off, off + len));
|
||||
if (mask) for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
||||
state.buf = b.subarray(off + len);
|
||||
if (op === 0x8) return onClose();
|
||||
if (op === 0x1) onText(payload.toString('utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Companion's side ─────────────────────────────────────────────────── */
|
||||
|
||||
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
|
||||
const pages = {
|
||||
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } },
|
||||
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
|
||||
p3: { id: 'p3', name: 'PAGE', controls: {} },
|
||||
};
|
||||
const order = ['p1', 'p2', 'p3'];
|
||||
const looks = {
|
||||
'bank:steady': png([200, 140, 0]),
|
||||
'bank:blue': png([20, 60, 200]),
|
||||
'bank:cam1': png([90, 90, 90]),
|
||||
'bank:cam2': png([120, 0, 120]),
|
||||
};
|
||||
const GREEN = png([0, 160, 60]);
|
||||
const RED = png([200, 20, 20]);
|
||||
let flipRed = false;
|
||||
looks['bank:flip'] = GREEN;
|
||||
|
||||
const clients = new Set();
|
||||
const stats = { connections: 0, pongs: 0, subscriptions: 0, stops: 0 };
|
||||
|
||||
function send(client, msg) {
|
||||
if (client.closed) return;
|
||||
try {
|
||||
client.socket.write(frame(typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
|
||||
function pageUpdate(change) {
|
||||
for (const c of clients) for (const [id, sub] of c.subs) if (sub.path === 'pages.watch') send(c, { id, result: { type: 'data', data: change } });
|
||||
}
|
||||
|
||||
function redraw(controlId) {
|
||||
for (const c of clients) {
|
||||
for (const [id, sub] of c.subs) {
|
||||
if (sub.path === 'preview.graphics.controlId' && sub.input.controlId === controlId) {
|
||||
send(c, { id, result: { type: 'data', data: looks[controlId] || null } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(client, text) {
|
||||
if (text === 'PONG') {
|
||||
client.awaitingPong = false;
|
||||
stats.pongs += 1;
|
||||
return;
|
||||
}
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'subscription.stop') {
|
||||
client.subs.delete(msg.id);
|
||||
stats.stops += 1;
|
||||
return send(client, { id: msg.id, result: { type: 'stopped' } });
|
||||
}
|
||||
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
|
||||
const { path, input } = msg.params || {};
|
||||
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
|
||||
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
|
||||
}
|
||||
stats.subscriptions += 1;
|
||||
client.subs.set(msg.id, { path, input });
|
||||
send(client, { id: msg.id, result: { type: 'started' } });
|
||||
if (path === 'pages.watch') {
|
||||
send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', order: [...order], pages: JSON.parse(JSON.stringify(pages)) } } });
|
||||
} else {
|
||||
send(client, { id: msg.id, result: { type: 'data', data: looks[input.controlId] || null } });
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('companion mock\n');
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket) => {
|
||||
if (new URL(req.url, 'http://x').pathname !== '/trpc') {
|
||||
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
const accept = crypto.createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
|
||||
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||
const client = { socket, subs: new Map(), state: { buf: Buffer.alloc(0) }, closed: false, awaitingPong: false };
|
||||
clients.add(client);
|
||||
stats.connections += 1;
|
||||
log(`client connected (${clients.size})`);
|
||||
const close = () => {
|
||||
if (client.closed) return;
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
socket.destroy();
|
||||
log(`client gone (${clients.size})`);
|
||||
};
|
||||
socket.on('data', (d) => readFrames(client.state, d, (t) => onMessage(client, t), close));
|
||||
socket.on('close', close);
|
||||
socket.on('error', close);
|
||||
});
|
||||
|
||||
const timers = [
|
||||
setInterval(() => {
|
||||
flipRed = !flipRed;
|
||||
looks['bank:flip'] = flipRed ? RED : GREEN;
|
||||
redraw('bank:flip');
|
||||
}, 2000),
|
||||
setInterval(() => redraw('bank:steady'), 300),
|
||||
setInterval(() => {
|
||||
for (const c of clients) {
|
||||
if (c.awaitingPong) {
|
||||
log('no PONG — dropping client');
|
||||
c.socket.destroy();
|
||||
continue;
|
||||
}
|
||||
c.awaitingPong = true;
|
||||
send(c, 'PING');
|
||||
}
|
||||
}, 30000),
|
||||
];
|
||||
if (renameAfter) {
|
||||
timers.push(setTimeout(() => {
|
||||
pages.p2.name = 'Cameras Renamed';
|
||||
pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [] }] });
|
||||
}, renameAfter * 1000));
|
||||
}
|
||||
if (moveAfter) {
|
||||
timers.push(setTimeout(() => {
|
||||
delete pages.p1.controls[0][0];
|
||||
pages.p2.controls[1][0] = 'bank:flip';
|
||||
pageUpdate({
|
||||
type: 'update',
|
||||
updatedOrder: null,
|
||||
added: [],
|
||||
changes: [
|
||||
{ id: 'p1', name: null, controls: [{ row: 0, column: 0, controlId: null }] },
|
||||
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:flip' }] },
|
||||
],
|
||||
});
|
||||
}, moveAfter * 1000));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
resolve({
|
||||
port: server.address().port,
|
||||
stats,
|
||||
clients,
|
||||
pageUpdate,
|
||||
redraw,
|
||||
looks,
|
||||
pages,
|
||||
/** Drop every client without a close frame, as a crash or a cable pull would. */
|
||||
dropAll() {
|
||||
for (const c of clients) c.socket.destroy();
|
||||
},
|
||||
close() {
|
||||
for (const t of timers) clearInterval(t);
|
||||
for (const c of clients) c.socket.destroy();
|
||||
return new Promise((r) => server.close(() => r()));
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { startMock, png };
|
||||
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? Number(args[i + 1]) || 0 : 0;
|
||||
};
|
||||
const port = Number(args.find((a) => /^\d+$/.test(a))) || 18000;
|
||||
startMock(port, { renameAfter: opt('--rename-after'), moveAfter: opt('--move-after'), log: (m) => console.log(`[companion-mock] ${m}`) }).then((m) => {
|
||||
console.log(`[companion-mock] listening on 127.0.0.1:${m.port} — point Companion Buttons at 127.0.0.1 port ${m.port}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'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);
|
||||
});
|
||||
Reference in New Issue
Block a user