'use strict' // Cabal's JS implementation is the reference side. Cable is exercised via // its CLI against the exact room key and admin URI generated by that side. // This deliberately does not call a Cable feed API: Cabal feed wire support // has not been connected to Cable yet. const assert = require('assert') const fs = require('fs') const os = require('os') const path = require('path') const { spawnSync } = require('child_process') const Cabal = require(process.env.CABAL_JS_MODULE || 'cabal-core') function callback (fn, ...args) { return new Promise((resolve, reject) => fn(...args, (err, value) => err ? reject(err) : resolve(value))) } function close (cabal) { return new Promise((resolve, reject) => cabal.close(err => err ? reject(err) : resolve())) } function cable (binary, home, ...args) { let command = binary let commandArgs = args let env = { ...process.env, HOME: home } if (process.env.CABLE_RUNNER === 'wsl') { // Node and cabal-core may run on Windows while Cable was built in WSL. // Convert both the executable and per-test HOME to paths visible in WSL. const wslPath = value => { const match = /^([A-Za-z]):[\\/](.*)$/.exec(value) if (!match) return value.replace(/\\/g, '/') return `/mnt/${match[1].toLowerCase()}/${match[2].replace(/\\/g, '/')}` } command = process.env.WSL_BIN || 'wsl.exe' commandArgs = ['--exec', 'env', `HOME=${wslPath(home)}`, wslPath(binary), ...args] env = { ...process.env } } const result = spawnSync(command, commandArgs, { encoding: 'utf8', env }) if (result.error) throw result.error if (result.status !== 0) throw new Error(`cable ${args.join(' ')} failed: ${result.stderr}`) return result.stdout.trim() } function waitForMessage (cabal, expected, timeoutMs) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cabal.messages.events.removeListener('message', onMessage) reject(new Error('Cabal JS replica did not receive chat/text record')) }, timeoutMs) function onMessage (message) { if (!message || !message.value || !message.value.content || message.value.content.text !== expected) return clearTimeout(timeout) cabal.messages.events.removeListener('message', onMessage) resolve(message) } cabal.messages.events.on('message', onMessage) }) } async function main () { const root = fs.mkdtempSync(path.join(process.env.CABAL_TEST_DIR || os.tmpdir(), 'cabal-cable-')) const home = path.join(root, 'cable-home') const binary = path.resolve(process.env.CABLE_BIN || 'build/linux/cable') fs.mkdirSync(home) let source, replica, otherRoom, sourceStream, replicaStream try { const sourceStorage = path.join(root, 'js-source') source = Cabal(sourceStorage) await callback(source.ready.bind(source)) const roomKey = String(source.key).replace(/^cabal:\/\//, '').toLowerCase() assert.match(roomKey, /^[0-9a-f]{64}$/, 'Cabal JS must create a 32-byte room key') replica = Cabal(path.join(root, 'js-replica'), roomKey) await callback(replica.ready.bind(replica)) const jsUserKey = await callback(source.getLocalKey.bind(source)) assert.match(jsUserKey, /^[0-9a-f]{64}$/, 'Cabal JS local writer key must be 32 bytes') const expectedText = 'hello from Cabal JS to its replicated room' const received = waitForMessage(replica, expectedText, 15000) sourceStream = source.replicate(true) replicaStream = replica.replicate(false) sourceStream.on('error', () => {}) replicaStream.on('error', () => {}) sourceStream.pipe(replicaStream).pipe(sourceStream) await callback(source.publishNick.bind(source), 'cable-side-by-side-user') await callback(source.publish.bind(source), { type: 'chat/text', content: { channel: 'cable-interop', text: expectedText } }) const replicated = await received assert.strictEqual(replicated.value.type, 'chat/text') assert.strictEqual(replicated.value.content.channel, 'cable-interop') const nickRecord = await callback(source.getMessage.bind(source), { key: jsUserKey, seq: 0 }) // cabal-core releases have returned either the Hypercore node wrapper or // its decoded value from getMessage; accept both documented representations. const nickValue = nickRecord.value || nickRecord assert.strictEqual(nickValue.type, 'about') assert.strictEqual(nickValue.content.name, 'cable-side-by-side-user') const replicatedNick = await callback(replica.getMessage.bind(replica), { key: jsUserKey, seq: 0 }) const replicatedNickValue = replicatedNick.value || replicatedNick assert.strictEqual(replicatedNickValue.type, 'about') assert.strictEqual(replicatedNickValue.content.name, 'cable-side-by-side-user') sourceStream.destroy() replicaStream.destroy() sourceStream = null replicaStream = null await close(source) source = Cabal(sourceStorage, roomKey) await callback(source.ready.bind(source)) assert.strictEqual(await callback(source.getLocalKey.bind(source)), jsUserKey, 'Cabal JS local user/feed address must persist for the same room') const adminKey = 'a'.repeat(64) const roomAddress = `cabal://${roomKey}?admin=${adminKey}` assert.strictEqual(cable(binary, home, 'address', roomAddress), `cable://${roomKey}?admin=${adminKey}`) const cableUser = cable(binary, home, 'whoami', roomAddress) assert.match(cableUser, /^cable:\/\/[0-9a-f]{64}$/) assert.notStrictEqual(cableUser.slice(8), roomKey, 'room key is not a user address') assert.notStrictEqual(cableUser.slice(8), adminKey, 'room admin key is not a user address') assert.notStrictEqual(cableUser.slice(8), jsUserKey, 'Cabal JS writer and Cable CLI represent separate local users') assert.strictEqual(cable(binary, home, 'whoami', `cable://${roomKey}`), cableUser, 'Cable user address must persist for the same room, regardless of URI scheme/admin option') otherRoom = Cabal(path.join(root, 'js-other-room')) await callback(otherRoom.ready.bind(otherRoom)) const otherRoomKey = String(otherRoom.key).replace(/^cabal:\/\//, '').toLowerCase() const otherJsUserKey = await callback(otherRoom.getLocalKey.bind(otherRoom)) assert.notStrictEqual(otherJsUserKey, jsUserKey, 'Cabal JS local writer keys must be independent across rooms') const otherCableUser = cable(binary, home, 'whoami', `cabal://${otherRoomKey}`) assert.notStrictEqual(otherCableUser, cableUser, 'Cable user address must be room-scoped like Cabal local writer feeds') process.stdout.write(`Cabal JS nick/feed replication + Cable room/user identity: PASS room=cabal://${roomKey} js-user=${jsUserKey} cable-user=${cableUser}\n`) } finally { for (const stream of [sourceStream, replicaStream]) if (stream) stream.destroy() for (const cabal of [source, replica, otherRoom]) if (cabal) await close(cabal) if (process.env.CABAL_TEST_KEEP === '1') process.stdout.write(`kept Cabal test state at ${root}\n`) else fs.rmSync(root, { recursive: true, force: true }) } } main().catch(err => { console.error(err); process.exitCode = 1 })