'use strict'; /** * Tests for Vista Cues. * * node modules/vista-cues/vista.test.js * * Plain Node, no dependencies; exits non-zero on the first failure. Covers * the cue-sheet parser, RTP-MIDI packet decoding, and a real session over * loopback UDP: a fake sender invites the module's session, plays notes, and * the server entry's state follows. */ const assert = require('assert'); const dgram = require('dgram'); const http = require('http'); const { parseMapping, match, noteFromName } = require('./mapping'); const { parseRtpMidi, describe, Session } = require('./rtpmidi'); let passed = 0; async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); } const wait = (ms) => new Promise((r) => setTimeout(r, ms)); async function until(fn, ms = 3000) { const end = Date.now() + ms; while (Date.now() < end) { if (fn()) return; await wait(20); } throw new Error('timed out waiting'); } /* ── a minimal independent sender ─────────────────────────────────────── */ function sessionPkt(cmd, token, ssrc, name) { const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`); const b = Buffer.alloc(16 + n.length); b.writeUInt16BE(0xffff, 0); b.write(cmd, 2, 'ascii'); b.writeUInt32BE(2, 4); b.writeUInt32BE(token, 8); b.writeUInt32BE(ssrc, 12); n.copy(b, 16); return b; } function rtp(ssrc, seq, midiBytes, flags = 0) { const midi = Buffer.from(midiBytes); const b = Buffer.alloc(13 + midi.length); b[0] = 0x80; b[1] = 0x61; b.writeUInt16BE(seq, 2); b.writeUInt32BE(1234, 4); b.writeUInt32BE(ssrc, 8); b[12] = flags | midi.length; midi.copy(b, 13); return b; } /** A sender that accepts invitations (like a Mac session) and can play notes. */ async function fakeSender(port, name) { const ssrc = 0x0badf00d; const control = dgram.createSocket('udp4'); const data = dgram.createSocket('udp4'); const peers = []; const syncs = []; let seq = 100; const handle = (sock, which) => (buf, r) => { const cmd = buf.toString('ascii', 2, 4); if (cmd === 'IN') { sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address); if (which === 'data') peers.push({ address: r.address, port: r.port }); } else if (cmd === 'CK') { syncs.push(buf[8]); if (buf[8] === 0) { const out = Buffer.from(buf); out.writeUInt32BE(ssrc, 4); out[8] = 1; data.send(out, r.port, r.address); } } }; control.on('message', handle(control, 'control')); data.on('message', handle(data, 'data')); await new Promise((r) => control.bind(port, '127.0.0.1', r)); await new Promise((r) => data.bind(port + 1, '127.0.0.1', r)); return { peers, syncs, note(n, ch = 1) { for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address); }, close() { control.close(); data.close(); }, }; } function freePort() { return new Promise((resolve) => { const s = dgram.createSocket('udp4'); s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); }); }); } /** Two adjacent free UDP ports, as a session needs. */ async function freePair() { for (let i = 0; i < 20; i++) { const p = await freePort(); if (p > 65000) continue; const ok = await new Promise((resolve) => { const s = dgram.createSocket('udp4'); s.on('error', () => resolve(false)); s.bind(p + 1, () => s.close(() => resolve(true))); }); if (ok) return p; } throw new Error('no free port pair'); } (async () => { console.log('mapping'); await test('reads the cue sheet forms', () => { const { rules, errors } = parseMapping([ '# comment', '60 = Walk-in', 'C3 - Worship 1', '61 release Walk-in', '62\tPause\tWorship 1', '127 release all', 'ch2:64 = Message # only on channel 2', 'pc5 = Baptism', 'note 66: Go: Song #2', '67 = "Back Lights"', 'F#2 = Hazer', ].join('\n')); assert.deepStrictEqual(errors, []); const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]); assert.deepStrictEqual(brief, [ [null, 'note', 60, 'go', 'Walk-in'], [null, 'note', 60, 'go', 'Worship 1'], [null, 'note', 61, 'release', 'Walk-in'], [null, 'note', 62, 'pause', 'Worship 1'], [null, 'note', 127, 'releaseAll', ''], [2, 'note', 64, 'go', 'Message'], [null, 'program', 5, 'go', 'Baptism'], [null, 'note', 66, 'go', 'Song #2'], [null, 'note', 67, 'go', 'Back Lights'], [null, 'note', 54, 'go', 'Hazer'], ]); }); await test('middle C naming', () => { assert.strictEqual(noteFromName('C3', 'C3'), 60); assert.strictEqual(noteFromName('C4', 'C4'), 60); assert.strictEqual(noteFromName('C-2', 'C3'), 0); assert.strictEqual(noteFromName('Bb3', 'C3'), 70); }); await test('bad lines are reported, not fatal', () => { const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK'); assert.strictEqual(rules.length, 1); assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]); }); await test('groups and flashes', () => { const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After'); assert.deepStrictEqual(errors, []); assert.deepStrictEqual(groups, ['Scene', 'Color']); assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [ [5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'], ]); }); await test('the NA cue sheet parses cleanly', () => { const fs = require('fs'); const path = require('path'); const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt'); if (!fs.existsSync(file)) return; const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8')); assert.deepStrictEqual(errors, []); assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']); assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length); }); await test('matching respects channel and kind', () => { const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program'); assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two'); assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any'); assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program'); assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null); }); console.log('rtp-midi'); await test('note on + running-status note off', () => { const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0])); assert.strictEqual(p.ssrc, 7); assert.strictEqual(p.seq, 9); assert.deepStrictEqual(p.messages.map(describe), [ { type: 'noteon', channel: 1, note: 60, velocity: 100 }, { type: 'noteoff', channel: 1, note: 60 }, ]); }); await test('Z flag, multi-byte deltas, program change, sysex in between', () => { const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20)); assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [ { type: 'program', channel: 3, program: 4 }, { type: 'noteon', channel: 4, note: 61, velocity: 9 }, ]); }); await test('journal bytes after the command section are ignored', () => { const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]); assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]); }); console.log('session'); await test('invites a sender, keeps sync, and hears its notes', async () => { const senderPort = await freePair(); const sender = await fakeSender(senderPort, 'Fake PP'); const port = await freePair(); const s = new Session({ name: 'Test', port }); assert.ok(await s.start()); const heard = []; s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note])); s.invite('127.0.0.1', senderPort, 'ProPresenter'); await until(() => s.list().some((p) => p.connected)); await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2 sender.note(60); await until(() => heard.length >= 2); assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]); s.stop(); sender.close(); await wait(80); }); await test('a config save re-inits on the same port straight away', async () => { const senderPort = await freePair(); const sender = await fakeSender(senderPort, 'Fake PP'); const port = await freePair(); const mod = require('./server'); const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } }; let handle = mod.init({ config, log: () => {} }); await until(() => handle.health().status === 'ok'); handle.stop(); handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init() await until(() => handle.health().status === 'ok'); handle.stop(); sender.close(); await wait(80); }); await test('a port taken by someone else is reported, then taken once free', async () => { const port = await freePair(); const squatter = dgram.createSocket('udp4'); await new Promise((r) => squatter.bind(port + 1, r)); const s = new Session({ name: 'Test', port }); let listening = false; s.on('listening', () => { listening = true; }); assert.strictEqual(await s.start(), false); assert.match(s.error, /in use/); squatter.close(); await until(() => listening, 5000); assert.strictEqual(s.error, ''); s.stop(); }); await test('server entry: notes become live cue lists, over HTTP', async () => { const senderPort = await freePair(); const sender = await fakeSender(senderPort, 'Fake PP'); const port = await freePair(); const mod = require('./server'); const handle = mod.init({ config: { sessionPort: port, advertise: false, source1Name: 'ProPresenter', source1: { host: '127.0.0.1', port: senderPort }, cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all', }, log: () => {}, }); const routes = mod.routes({}); const server = http.createServer((req, res) => { const r = routes[`${req.method} ${req.url}`]; if (r) r(req, res); else { res.writeHead(404); res.end(); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const get = async (path, method = 'GET') => { const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method }); return res.json(); }; const state = async () => (await get('/state')).state; await until(() => sender.peers.length > 0); await until(() => handle.health().status === 'ok'); sender.note(60); await wait(100); sender.note(61); await wait(100); let st = await state(); assert.strictEqual(st.current.name, 'Worship 1'); assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']); assert.strictEqual(st.current.source, 'ProPresenter'); assert.strictEqual(st.sources[0].connected, true); sender.note(70); sender.note(99); // unmapped await wait(150); st = await state(); assert.deepStrictEqual(st.active, []); assert.strictEqual(st.log[0].mapped, false); assert.match(st.log[0].trigger, /Note 99/); sender.note(127); await wait(100); st = await state(); assert.strictEqual(st.current, null); sender.note(60); await wait(100); await get('/clear', 'POST'); st = await state(); assert.strictEqual(st.current, null); assert.strictEqual(st.log[0].action, 'cleared'); handle.stop(); // Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out. const h2 = mod.init({ config: { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort }, cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1', }, log: () => {}, }); await until(() => h2.health().status === 'ok'); for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); } await wait(100); st = await state(); assert.strictEqual(st.current.name, 'Pre Service'); assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red assert.strictEqual(st.log[0].action, 'flash'); sender.note(8); // a new main scene takes over the look… await wait(100); st = await state(); assert.strictEqual(st.current.name, 'Message'); assert.deepStrictEqual(st.active, []); sender.note(22); // …and FX fired after it layer on top again await wait(100); st = await state(); assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']); h2.stop(); server.close(); sender.close(); await wait(80); }); console.log(`\n${passed} passed`); process.exit(0); })().catch((e) => { console.error(e); process.exit(1); });