diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 521da8e9..ebd9dec9 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -5,7 +5,11 @@ on: branches: [master] jobs: + publish-test: + uses: ./.github/workflows/test.yml + publish-npm: + needs: publish-test environment: npm runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7dce92d6..789a8d19 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -6,16 +6,4 @@ on: jobs: build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18, 20, 22, 24, 26] - - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm run build + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..4a560129 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test Pull Request + +on: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + + services: + nexusd: + image: ghcr.io/gammazero/nexusd:latest + ports: + - 8080:8080 + command: -realm test1 -ws 0.0.0.0:8080 + + strategy: + matrix: + node-version: [24, 26] + + container: node:${{ matrix.node-version }}-bookworm-slim + + steps: + - uses: actions/checkout@v7 + - run: npm ci + - run: npm run check + - run: npm test + env: + WAMP_ROUTER_URL: ws://nexusd:8080 diff --git a/.npmignore b/.npmignore index 9da6ac2c..3349a87d 100644 --- a/.npmignore +++ b/.npmignore @@ -8,4 +8,5 @@ commitlint.config.cjs docker-compose.yml prettier.config.cjs renovate.json +test tsconfig.json diff --git a/package.json b/package.json index f07af207..166388ca 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "scripts": { "build": "tsc", + "check": "tsc --noEmit", "clean": "rimraf src/*.{js,d.ts}", "start": "vite-node src/program.ts", + "test": "node --test test/**/*.test.ts", "prepare": "husky" }, "dependencies": { diff --git a/src/program.ts b/src/program.ts index ba9118ec..b8b595c9 100644 --- a/src/program.ts +++ b/src/program.ts @@ -2,7 +2,7 @@ import { Connection } from 'autobahn' import { program } from 'commander' -import * as repl from './repl.js' +import * as repl from './repl.ts' import 'colors' import { createRequire } from 'node:module' diff --git a/src/repl.ts b/src/repl.ts index f10bbf0e..321ac103 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -30,149 +30,80 @@ export const start = (connection: Connection) => (session: Session) => { replServer.defineCommand('SUB', { help: 'Subscript to a topic.', - async action(topic) { - if (!topic) { - console.error('Usage: .SUB '.red) - this.displayPrompt() - return - } - - try { - await session.subscribe(topic.trim(), (args, kwargs) => { - process.stdout.write('\r') - console.info( - 'PUB>'.cyan, - `${topic}>`.yellow, - inspect({ args, kwargs }, false, 10), - ) - this.displayPrompt() - }) - console.info('Subscribed to', topic.green) - } catch (error) { - console.error( - `Error: ${error instanceof Error ? error.message : String(error)}` - .red, + action: command('Usage: .SUB ', async function (topic) { + await session.subscribe(topic, (args, kwargs) => { + process.stdout.write('\r') + console.info( + 'PUB>'.cyan, + `${topic}>`.yellow, + inspect({ args, kwargs }, false, 10), ) - } - - this.displayPrompt() - }, + this.displayPrompt() + }) + console.info('Subscribed to', topic.green) + }), }) replServer.defineCommand('PUB', { help: 'Publish to a topic.', - action(topic) { - if (!topic) { - console.error('Usage: .PUB '.red) - this.displayPrompt() - return - } - - this.question('Enter args (YAML array)> '.magenta, (argsInput) => { - this.question( - 'Enter kwargs (YAML object)> '.magenta, - async (kwargsInput) => { - try { - const args = argsInput.trim() ? YAML.parse(argsInput) : [] - const kwargs = kwargsInput.trim() ? YAML.parse(kwargsInput) : {} - - await session.publish(topic.trim(), args, kwargs, { - exclude_me: false, - }) - console.info( - 'Published to', - topic.green, - inspect({ args, kwargs }, false, 10).yellow, - ) - } catch (error) { - console.error( - `Error: ${ - error instanceof Error ? error.message : String(error) - }`.red, - ) - } finally { - this.displayPrompt() - } - }, - ) + action: command('Usage: .PUB ', async function (topic) { + const args = await question(this, 'Enter args (YAML array)> '.magenta, []) + + const kwargs = await question( + this, + 'Enter kwargs (YAML object)> '.magenta, + {}, + ) + + await session.publish(topic.trim(), args, kwargs, { + exclude_me: false, }) - }, + + console.info( + 'Published to', + topic.green, + inspect({ args, kwargs }, false, 10).yellow, + ) + }), }) replServer.defineCommand('REG', { help: 'Register a RPC endpoint.', - async action(procedure) { - if (!procedure) { - console.error('Usage: . '.red) - this.displayPrompt() - return - } - - try { - await session.register(procedure.trim(), (args, kwargs) => { - process.stdout.write('\r') - console.info( - 'CALL>'.cyan, - `${procedure}>`.yellow, - inspect({ args, kwargs }, false, 10), - ) - this.displayPrompt() - }) - console.info('Register a', procedure.green, 'endpoint') - } catch (error) { - console.error( - `Error: ${error instanceof Error ? error.message : String(error)}` - .red, + action: command('Usage: .REG ', async function (procedure) { + await session.register(procedure.trim(), (args, kwargs) => { + process.stdout.write('\r') + console.info( + 'CALL>'.cyan, + `${procedure}>`.yellow, + inspect({ args, kwargs }, false, 10), ) - } finally { this.displayPrompt() - } - }, + }) + console.info('Register a', procedure.green, 'endpoint') + }), }) replServer.defineCommand('CALL', { help: 'Call a RPC endpoint', - async action(procedure) { - if (!procedure) { - console.error('Usage: .CALL '.red) - this.displayPrompt() - return - } - - this.question('Enter args (YAML array)> '.magenta, (argsInput) => { - this.question( - 'Enter kwargs (YAML object)> '.magenta, - async (kwargsInput) => { - try { - const args = argsInput.trim() ? YAML.parse(argsInput) : [] - const kwargs = kwargsInput.trim() ? YAML.parse(kwargsInput) : {} - - const response = await session.call( - procedure.trim(), - args, - kwargs, - ) - - console.info( - 'Called', - procedure.green, - inspect({ args, kwargs }, false, 10).yellow, - ) - - console.info('RES>'.cyan, response) - } catch (error) { - console.error( - `Error: ${ - error instanceof Error ? error.message : String(error) - }`.red, - ) - } finally { - this.displayPrompt() - } - }, - ) - }) - }, + action: command('Usage: .CALL ', async function (procedure) { + const args = await question(this, 'Enter args (YAML array)> '.magenta, []) + + const kwargs = await question( + this, + 'Enter kwargs (YAML object)> '.magenta, + {}, + ) + + const response = await session.call(procedure, args, kwargs) + + console.info( + 'Called', + procedure.green, + inspect({ args, kwargs }, false, 10).yellow, + ) + + console.info('RES>'.cyan, response) + }), }) replServer.on('reset', reset) @@ -184,3 +115,41 @@ export const start = (connection: Connection) => (session: Session) => { replServer.once('exit', () => connection.close()) } } + +function command( + usage: string, + cmd: (this: repl.REPLServer, input: string) => Promise, +) { + return async function (this: repl.REPLServer, input: string) { + const $input = input.trim() + + if (!$input) { + console.error(usage.red) + this.displayPrompt() + return + } + + try { + await cmd.call(this, $input) + } catch (error) { + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`.red, + ) + } finally { + this.displayPrompt() + } + } +} + +function question( + replServer: repl.REPLServer, + question: string, + dflt: T, +): Promise { + return new Promise((resolve) => { + replServer.question(question, (answer) => { + const trimmedAnswer = answer.trim() + resolve(trimmedAnswer ? YAML.parse(trimmedAnswer) : dflt) + }) + }) +} diff --git a/test/repl.test.ts b/test/repl.test.ts new file mode 100644 index 00000000..c5adedf1 --- /dev/null +++ b/test/repl.test.ts @@ -0,0 +1,195 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { fileURLToPath } from 'node:url' + +const DEFAULT_ROUTER_URL = 'ws://localhost:8080/' +const DEFAULT_REALM = 'test1' + +test('REPL connects to the live router', { timeout: 30_000 }, async () => { + await using replSession = await startReplSession() + await waitForOutput(replSession.readOutput, /Connected/, 10_000) + assert.match(replSession.readOutput(), /Connected/) +}) + +test('REPL subscribes to a topic', { timeout: 30_000 }, async () => { + await using replSession = await startReplSession() + const topic = uniqueTopic() + + await waitForOutput(replSession.readOutput, /Connected/, 10_000) + replSession.send(`.SUB ${topic}`) + await waitForOutput( + replSession.readOutput, + new RegExp(`Subscribed to\\s+${escapeRegExp(topic)}`), + 10_000, + ) + + assert.match( + replSession.readOutput(), + new RegExp(`Subscribed to\\s+${escapeRegExp(topic)}`), + ) +}) + +test('REPL publishes to a topic', { timeout: 30_000 }, async () => { + await using replSession = await startReplSession() + const topic = uniqueTopic() + + await waitForOutput(replSession.readOutput, /Connected/, 10_000) + replSession.send(`.PUB ${topic}`) + await waitForOutput( + replSession.readOutput, + /Enter args \(YAML array\)>/, + 10_000, + ) + replSession.send('[1, "two"]') + await waitForOutput( + replSession.readOutput, + /Enter kwargs \(YAML object\)>/, + 10_000, + ) + replSession.send('{ok: true, from: e2e}') + await waitForOutput( + replSession.readOutput, + new RegExp(`Published to\\s+${escapeRegExp(topic)}`), + 10_000, + ) + + assert.match( + replSession.readOutput(), + new RegExp(`Published to\\s+${escapeRegExp(topic)}`), + ) +}) + +test( + 'REPL receives event on subscribed topic', + { timeout: 30_000 }, + async () => { + await using replSession = await startReplSession() + const topic = uniqueTopic() + + await waitForOutput(replSession.readOutput, /Connected/, 10_000) + replSession.send(`.SUB ${topic}`) + await waitForOutput( + replSession.readOutput, + new RegExp(`Subscribed to\\s+${escapeRegExp(topic)}`), + 10_000, + ) + + replSession.send(`.PUB ${topic}`) + await waitForOutput( + replSession.readOutput, + /Enter args \(YAML array\)>/, + 10_000, + ) + replSession.send('[1, "two"]') + await waitForOutput( + replSession.readOutput, + /Enter kwargs \(YAML object\)>/, + 10_000, + ) + replSession.send('{ok: true, from: e2e}') + await waitForOutput( + replSession.readOutput, + new RegExp(`PUB>\\s+${escapeRegExp(topic)}>`), + 10_000, + ) + + assert.match( + replSession.readOutput(), + new RegExp(`PUB>\\s+${escapeRegExp(topic)}>`), + ) + }, +) + +async function startReplSession() { + const routerUrl = process.env.WAMP_ROUTER_URL ?? DEFAULT_ROUTER_URL + const realm = process.env.WAMP_REALM ?? DEFAULT_REALM + const entrypointPath = fileURLToPath( + new URL('../src/program.ts', import.meta.url), + ) + const cwd = fileURLToPath(new URL('..', import.meta.url)) + + const child = spawn(process.execPath, [entrypointPath, routerUrl, realm], { + cwd, + env: { ...process.env, FORCE_COLOR: '0' }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let output = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + output += chunk + }) + child.stderr.on('data', (chunk: string) => { + output += chunk + }) + + return { + send(command: string) { + child.stdin.write(`${command}\n`) + }, + readOutput() { + return stripAnsi(output) + }, + async [Symbol.asyncDispose]() { + { + if (child.killed || child.exitCode !== null) { + return + } + + child.stdin.write('.exit\n') + + const closed = Promise.race([ + once(child, 'exit'), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error('child did not exit in time')), + 2_000, + ) + }), + ]) + + try { + await closed + } catch { + child.kill('SIGKILL') + await once(child, 'exit') + } + } + }, + } +} + +function uniqueTopic() { + return `wamp-cli.e2e.${Date.now()}.${Math.random().toString(36).slice(2, 8)}` +} + +async function waitForOutput( + readOutput: () => string, + pattern: RegExp, + timeoutMs: number, +) { + const start = Date.now() + + while (Date.now() - start < timeoutMs) { + if (pattern.test(readOutput())) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + throw new Error( + `Timed out waiting for output pattern: ${pattern.source}\nCurrent output:\n${readOutput()}`, + ) +} + +function stripAnsi(text: string) { + return text.replace(/\u001b\[[0-9;]*m/g, '') +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..5a263afd --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".." + }, + "include": [".", "../src"] +} diff --git a/tsconfig.json b/tsconfig.json index 1f539638..d6d6d460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ "verbatimModuleSyntax": true, "importHelpers": true, "declaration": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, // Best practices "strict": true,