/** * End-to-end self test without Companion: mock console <-> module link/relay <-> fake iPad. * * node test/selftest.js * * Ports (all on 127.0.0.1, chosen to avoid anything real): * mock console listens 18000, replies to 127.0.0.1:19000 (console "receive" / "send") * module link binds 19000, sends to 18000 * module relay binds 18001, fans out to iPad port 19001 * fake iPad binds 19001, sends to 18001 */ import { spawn } from 'node:child_process' import dgram from 'node:dgram' import path from 'node:path' import { fileURLToPath } from 'node:url' import { ConsoleLink } from '../src/console-link.js' import { Relay } from '../src/relay.js' import { getDialect } from '../src/dialects.js' import { encode, decode, describe } from '../src/osc.js' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) let failures = 0 function check(ok, what) { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`) if (!ok) failures++ } // 0. The instance class must at least import cleanly with the real base library. const mainMod = await import('../src/main.js') check(typeof mainMod.default === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports default class + UpgradeScripts') const inst = Object.create(mainMod.default.prototype) inst.config = { monSend: 24, monLabel: 'Studio Mon', channels: 4, monMode: 'auto' } Object.defineProperty(inst, 'label', { value: 'digico' }) inst.dialect = getDialect('sd') inst.state = { auxNames: new Map([[24, '']]), groupNames: new Map([[24, 'Studio']]), sends: new Map(), groups: new Map(), mutes: new Map(), bussMutes: new Map() } check(inst.monMode() === 'group', 'monMode() auto-detects a group buss from the names') const defs = await import('../src/definitions.js') const actions = defs.getActionDefinitions(inst) const feedbacks = defs.getFeedbackDefinitions(inst) const variables = defs.getVariableDefinitions(inst) const { structure, presets } = defs.getPresetDefinitions(inst) check(Object.keys(actions).length >= 15 && Object.keys(feedbacks).length >= 8, `definitions build (${Object.keys(actions).length} actions, ${Object.keys(feedbacks).length} feedbacks, ${Object.keys(variables).length} variables, ${Object.keys(presets).length} presets, ${structure.length} preset groups)`) // 1. Codec round trips, including the unpadded-datagram case the iPad app produces. { const buf = encode('/Input_Channels/3/Aux_Send/2/send_on', [{ i: 1 }]) const m = decode(buf) check(m.address === '/Input_Channels/3/Aux_Send/2/send_on' && m.tags === ',i' && m.args[0] === 1, 'OSC encode/decode int') const f = decode(encode('/x', [-12.5, 'hi', true, false, null])) check(Math.abs(f.args[0] + 12.5) < 1e-6 && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N') const short = decode(buf.subarray(0, buf.length - 2)) check(short.unpadded === true && short.address === m.address, 'decoder tolerates an unpadded datagram') check(decode(Buffer.from('garbage')).raw !== undefined, 'decoder returns raw for non-OSC bytes') } // 2. Start the mock console (SD dialect) and a fake iPad. const node = process.execPath const mock = spawn(node, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', '18000', '--reply-to', '127.0.0.1:19000', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] }) let mockOut = '' mock.stdout.on('data', (d) => (mockOut += d)) mock.stderr.on('data', (d) => (mockOut += d)) await sleep(400) const link = new ConsoleLink({ host: '127.0.0.1', port: 18000, localPort: 19000 }) const relay = new Relay({ port: 18001, clientPort: 19001, timeoutMs: 60000 }) const dialect = getDialect('sd') const state = { names: new Map(), auxNames: new Map(), groupNames: new Map(), sends: new Map(), groups: new Map(), pongs: 0, counts: {} } link.on('raw', (buf) => relay.fanout(buf)) link.on('message', (msg) => { const ev = dialect.parse(msg.address, msg.args || []) if (!ev) return if (ev.kind === 'pong') state.pongs++ if (ev.kind === 'name') state.names.set(ev.ch, ev.value) if (ev.kind === 'auxName') state.auxNames.set(ev.aux, ev.value) if (ev.kind === 'groupName') state.groupNames.set(ev.group, ev.value) if (ev.kind === 'count') state.counts[ev.field] = ev.value if (ev.kind === 'send') { const k = `${ev.ch}:${ev.send}` state.sends.set(k, { ...(state.sends.get(k) || {}), [ev.field]: ev.value }) } if (ev.kind === 'group') state.groups.set(`${ev.ch}:${ev.group}`, ev.value) }) relay.on('fromClient', (buf) => link.sendRaw(buf)) await link.open() await relay.open() // 3. Module-style load: paced queries. const queries = dialect.resendQueries({ inputs: 8, auxes: 25, monSend: 24 }) for (const [a, args] of queries) { link.send(a, args) await sleep(4) } await sleep(800) check(state.pongs >= 1, 'console answered /Console/Name/? (keepalive works)') check(state.counts.inputs === 60 && state.counts.auxes === 25, `counts loaded (inputs=${state.counts.inputs}, auxes=${state.counts.auxes})`) check(state.names.get(1) === 'KICK In', `input names loaded (1 = ${JSON.stringify(state.names.get(1))})`) check(state.auxNames.get(1) === 'VOCAL VERB', `aux names loaded (1 = ${JSON.stringify(state.auxNames.get(1))})`) const s = state.sends.get('1:24') check(s && typeof s.level === 'number' && typeof s.enabled === 'boolean', `send state for ch1 buss24 loaded (${JSON.stringify(s)})`) check(state.groupNames.get(24) === 'Studio' && state.auxNames.get(24) === '', `group names loaded (24 = ${JSON.stringify(state.groupNames.get(24))}, aux side ${JSON.stringify(state.auxNames.get(24))})`) check(state.groups.get('1:24') === true && state.groups.size >= 8, `group assigns loaded for buss 24 (${state.groups.size} known, ch1 = ${state.groups.get('1:24')})`) link.send(...dialect.groupAssign(5, 24, false, 'int')) await sleep(200) check(state.groups.get('5:24') === false, 'group assign write (int) echoed by console and parsed as boolean') // 4. A write from the module leg is echoed back by the console. link.send(...dialect.sendLevel(2, 24, -12.5)) await sleep(200) check(Math.abs((state.sends.get('2:24')?.level ?? 999) + 12.5) < 0.01, 'send_level write echoed by console and parsed') link.send(...dialect.sendEnabled(2, 24, true, 'int')) await sleep(200) check(state.sends.get('2:24')?.enabled === true, 'send_on write (int) echoed by console and parsed as boolean') // 5. Fake iPad through the relay: it must get console replies and its writes must reach the console. const ipadRx = [] const ipad = dgram.createSocket('udp4') ipad.on('message', (buf) => ipadRx.push(decode(buf))) await new Promise((r) => ipad.bind(19001, '127.0.0.1', r)) ipad.send(encode('/Console/Name/?', []), 18001, '127.0.0.1') await sleep(300) check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via relay (${ipadRx.length} msgs)`) check(relay.clientCount === 1, 'relay tracks one iPad client') ipad.send(encode('/Input_Channels/3/Aux_Send/24/send_level', [-20]), 18001, '127.0.0.1') await sleep(300) check(Math.abs((state.sends.get('3:24')?.level ?? 999) + 20) < 0.01, 'iPad write relayed to console; echo parsed by module') check(ipadRx.some((m) => m.address === '/Input_Channels/3/Aux_Send/24/send_level'), 'console echo of the iPad write fanned back to the iPad') // 6. Unpadded datagram from an "iPad" is forwarded verbatim and still answered. const unpadded = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 0) // (already padded; mimic by trimming trailing zero bytes) const trimmed = unpadded.subarray(0, unpadded.length - 2) const before = ipadRx.length ipad.send(trimmed, 18001, '127.0.0.1') await sleep(300) console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (mock may or may not accept unpadded input)`) ipad.close() relay.close() link.close() mock.kill('SIGTERM') await sleep(200) if (failures) { console.log(`\n${failures} check(s) failed. Mock output:\n${mockOut.slice(-2000)}`) process.exit(1) } console.log('\nAll checks passed.')