diff --git a/src/kvparse.js b/src/kvparse.js new file mode 100644 index 00000000..58a78aeb --- /dev/null +++ b/src/kvparse.js @@ -0,0 +1,93 @@ +// Parse 'key=val key="val" key="val val2" "key name"=val' into an object +module.exports = function kvParse(inp) { + let data = {}; + let pos = 0; + let escapeChar = '\\'; + + while (pos < inp.length) { + let key = ''; + let val = ''; + + key = readToken(); + ffwd(); + if (inp[pos] === '=') { + skip(); + val = readToken({isValue: true}); + } else { + ffwd(); + val = true; + } + + data[key] = val; + } + + return data; + + // Fast forward past whitespace + function ffwd() { + while (inp[pos] === ' ' && pos < inp.length) { + pos++; + } + } + + // Skip the current position + function skip() { + pos++; + } + + // Read a block of characters. Quoted allows spaces + function readToken(opts={isValue:false}) { + let inQuote = false; + let buffer = ''; + + ffwd(); + do { + let cur = inp[pos]; + if (!cur) { + break; + } + + // Opening quote + if (!inQuote && isQuote(cur)) { + inQuote = true; + continue; + } + + // Escaped closing quote = not a closing quote + if (inQuote && isQuote(cur) && isEscaped()) { + buffer += cur; + continue; + } + + // Closing quote + if (inQuote && isQuote(cur)) { + inQuote = false; + skip(); + break; + } + + if (!opts.isValue) { + if (!inQuote && (cur === ' ' || cur === '=')) { + break; + } + } else { + // Values allow = characters + if (!inQuote && cur === ' ') { + break; + } + } + + buffer += cur; + } while(++pos < inp.length) + + return buffer; + } + + function isQuote(char) { + return char === '"'; + } + + function isEscaped() { + return inp[pos-1] === escapeChar; + } +} diff --git a/test/protocol.test.js b/test/protocol.test.js new file mode 100644 index 00000000..7b58829b --- /dev/null +++ b/test/protocol.test.js @@ -0,0 +1,9 @@ +'use strict'; +/* globals describe, it */ +const TestProtocol = require('../test_protocol/'); + +describe('protocol test runners', async function() { + it('should run all protocol test scripts', async function() { + await TestProtocol() + }); +}); diff --git a/test_protocol/index.js b/test_protocol/index.js new file mode 100644 index 00000000..645eee48 --- /dev/null +++ b/test_protocol/index.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const TestRunner = require('./testrunner'); +const TestRunnerTransport = require('./testrunnertransport'); +const IRC = require('../src/'); + +module.exports = runScripts; + +isMain = require.main === module; + +(async function() { + if (isMain) { + await runScripts(); + } +})(); + +async function runScripts() { + // Run through each test runner script and run it + let scriptsDir = __dirname + '/test_scripts/'; + let scripts = fs.readdirSync(scriptsDir); + for(let i=0; i { + createNewIrcClient(); + }; + + // Start running the test runner before creating the client to be sure all events are caught + let scriptRun = r.run(); + createNewIrcClient(); + await scriptRun; + if (bot) { + bot.connection.end(); + } + + function createNewIrcClient() { + if (bot) { + bot.connection.end(); + } + + bot = new IRC.Client(); + bot.use(CatchAllMiddleware(r)); + bot.connect({ + transport: createTestRunnerTransport(r), + host: 'irc.example.net', + nick: 'ircfrw_testrunner', + }); + bot.on('registered', function() { + bot.join('#prawnsalad'); + }); + bot.on('join', event => { + if (event.nick === bot.user.nick) { + bot.who(event.channel); + } + }); + + if (isMain) { + //bot.on('debug', l => console.log('[debug]', l)); + bot.on('raw', event => console.log(`[raw ${event.from_server?'s':'c'}]`, event.line)); + } + } +}; + +// Create an irc-framework transport +function createTestRunnerTransport(r) { + return function(...args) { + return new TestRunnerTransport(r, ...args) + }; +} + +// Pass all framework events to TestRunner r +function CatchAllMiddleware(r) { + return function(client, raw_events, parsed_events) { + parsed_events.use(theMiddleware); + }; + + function theMiddleware(command, event, client, next) { + r.addEventFromClient(command, event) + next(); + } +} diff --git a/test_protocol/test_scripts/register_using_readwait.txt b/test_protocol/test_scripts/register_using_readwait.txt new file mode 100644 index 00000000..162faffd --- /dev/null +++ b/test_protocol/test_scripts/register_using_readwait.txt @@ -0,0 +1,10 @@ +# Testing IRC registration without CAP support + +# wait until we get the USER command +READWAIT USER $ $ $ $ + +# register the client with the nick "testbot" +SEND :src 001 testbot something :Welcome home + +# the client should now trigger a "registered" event +EVENT registered nick=testbot \ No newline at end of file diff --git a/test_protocol/test_scripts/register_without_cap.txt b/test_protocol/test_scripts/register_without_cap.txt new file mode 100644 index 00000000..33643cb0 --- /dev/null +++ b/test_protocol/test_scripts/register_without_cap.txt @@ -0,0 +1,6 @@ +# Testing IRC registration without CAP support +READ CAP LS 302 +READ NICK $nick +READ USER $ $ $ $ +SEND :src 001 $nick something :Welcome home +EVENT registered nick=$nick \ No newline at end of file diff --git a/test_protocol/test_scripts/script_reset_test.txt b/test_protocol/test_scripts/script_reset_test.txt new file mode 100644 index 00000000..738fdcc0 --- /dev/null +++ b/test_protocol/test_scripts/script_reset_test.txt @@ -0,0 +1,20 @@ +# Test script RESET example + +READWAIT USER $ $ $ $ +SEND :src 001 nick1 something :Welcome home +EVENT registered nick=nick1 + +RESET +READWAIT USER $ $ $ $ +SEND :src 001 nick2 something :Welcome home +EVENT registered nick=nick2 + +RESET +READWAIT USER $ $ $ $ +SEND :src 001 nick3 something :Welcome home +EVENT registered nick=nick3 + +RESET +READWAIT USER $ $ $ $ +SEND :src 001 nick4 something :Welcome home +EVENT registered nick=nick4 diff --git a/test_protocol/test_scripts/who_after_join.txt b/test_protocol/test_scripts/who_after_join.txt new file mode 100644 index 00000000..efdd8758 --- /dev/null +++ b/test_protocol/test_scripts/who_after_join.txt @@ -0,0 +1,11 @@ +# Testing sending WHO after joining a channel +READ CAP LS 302 +READ NICK $nick +READ USER $ $ $ $ +SEND :src 001 $nick something :Welcome home +SEND :src 005 $nick a b c :is supported +READ JOIN $chan +SEND :$nick JOIN $chan +EVENTWAIT join channel="$chan" nick=$nick +READ WHO $1 +EXPECT $1="$chan" diff --git a/test_protocol/testrunner.js b/test_protocol/testrunner.js new file mode 100644 index 00000000..7372a1eb --- /dev/null +++ b/test_protocol/testrunner.js @@ -0,0 +1,262 @@ +const kvParse = require('../src/kvparse'); + +// Splits a string but stops splitting at the first match +function splitOnce(inp, sep=' ') { + let p1, p2 = ''; + let pos = inp.indexOf(sep); + if (pos === -1) { + p1 = inp; + } else { + p1 = inp.substr(0, pos); + p2 = inp.substr(pos + 1); + } + return [p1, p2]; +} + +// A simple promise based Queue +class Queue { + constructor() { + this.items = []; + this.waiting = []; + } + + add(item) { + this.items.push(item); + setTimeout(() => { + this.deliver(); + }); + } + + get() { + let res = null; + let prom = new Promise(resolve => res = resolve); + prom.resolve = res; + this.waiting.push(prom); + setTimeout(() => { + this.deliver(); + }); + return prom; + } + + flush() { + this.waiting.forEach(w => w.resolve()); + } + + deliver() { + if (this.waiting.length > 0 && this.items.length > 0) { + this.waiting.shift().resolve(this.items.shift()); + } + } +} + +class RunnerError extends Error { + constructor(step, message, runner) { + let errMessage = runner && runner.description ? + `[${runner.description}] ` : + ''; + if (step) { + errMessage += `at test line ${step.sourceLineNum}: ` + } + super(errMessage + message); + this.name = 'RunnerError'; + } +} + +// A single actionable step from a test script +class TestStep { + constructor(command, args) { + this.command = command; + this.args = args; + this.sourceLineNum = 0; + } +} + + +// Execute a test script +class TestRunner { + constructor() { + this.description = ''; + this.steps = []; + this.vars = new Map(); + this.clientBuffer = new Queue(); + this.clientEvents = new Queue(); + this.onSendLine = (line) => {}; + this.onReset = () => {}; + } + + load(input) { + let firstComment = ''; + + let steps = []; + input.split('\n').forEach((line, lineNum) => { + // Strip out empty lines and comments, keeping track of line numbers for kept lines + let trimmed = line.trim(); + if (!trimmed) { + return; + } + + // Comment + if (trimmed[0] === '#') { + firstComment = firstComment || trimmed.replace(/^[# ]+/, '').trim(); + return; + } + + let [command, args] = splitOnce(trimmed); + let step = new TestStep(command.toUpperCase(), args.trim()); + step.sourceLineNum = lineNum+1; + steps.push(step); + }); + + this.description = firstComment; + this.steps = steps; + } + + async run() { + for(let i=0; i { + let varName = this.varName(stepArg); + if (varName) { + this.vars.set(varName, lineParts[idx]); + } else if (varName === '') { + // empty var name = ignore this value + } else { + if (stepArg !== lineParts[idx]) { + throw new RunnerError(step, `READ expected '${stepArg}', got '${lineParts[idx]}'`, this); + } + } + }); + } + + async commandSEND(step) { + let line = step.args.replace(/(^|\W)\$([a-z0-9_]+)/g, (_, prefix, varName) => { + return prefix + (this.vars.get(varName) || '-'); + }); + + if (typeof this.onSendLine === 'function') { + this.onSendLine(line); + } + } + + async commandEXPECT(step) { + let checks = kvParse(step.args); + for (let prop in checks) { + // Both the key or value could be a variable + let key = this.varName(prop); + key = key === false ? + prop : + this.vars.get(key); + + let val = this.varName(checks[prop]); + val = val === false ? + checks[prop] : + this.vars.get(val); + + if (key !== val) { + throw new RunnerError(step, `EXPECT failed to match '${key}'='${val}'`, this); + } + } + } + + async commandEVENTWAIT(step) { + let [eventName] = splitOnce(step.args); + return this.commandEVENT(step, eventName); + } + + async commandEVENT(step, waitForEventName='') { + let pos = step.args.indexOf(' '); + let eventName = step.args.substr(0, pos); + let checks = kvParse(step.args.substr(pos)); + let name, event = null; + + if (waitForEventName) { + // Ignore all events until we find the one we want + while (name !== waitForEventName) { + ({name, event} = await this.clientEvents.get()); + } + } else { + ({name, event} = await this.clientEvents.get()); + } + + if (name !== eventName) { + throw new RunnerError(step, `EVENT expected event name of '${eventName}', found '${name}'`, this); + } + + for (let key in checks) { + let val = this.varName(checks[key]); + val = val === false ? + checks[key] : + this.vars.get(val); + + if (event[key] !== val) { + throw new RunnerError(step, `EVENT failed to match property '${key}'='${val}', found '${event[key]}'`, this); + } + } + } + + varName(inp) { + return inp[0] === '$' ? + inp.substr(1) : + false; + } +} + +module.exports = TestRunner; diff --git a/test_protocol/testrunnertransport.js b/test_protocol/testrunnertransport.js new file mode 100644 index 00000000..c982cbc9 --- /dev/null +++ b/test_protocol/testrunnertransport.js @@ -0,0 +1,50 @@ +const EventEmitter = require('eventemitter3'); + +class Transport extends EventEmitter { + constructor(r) { + super(); + + this.connected = false; + this.r = r; + this.r.onSendLine = line => { + // server -> client data + this.emit('line', line + '\n'); + }; + } + + isConnected() { + return true; + } + + writeLine(line, cb) { + this.r.addLineFromClient(line); + cb && setTimeout(cb); + } + + connect() { + setTimeout(() => { + this.connected = true; + this.emit('open'); + }); + } + + disposeSocket() { + if (this.connected) { + this.close(); + } + } + + close() { + if (this.connected) { + setTimeout(() => { + this.connected = false; + this.emit('close', false); + }); + } + } + + setEncoding(encoding) { + } +}; + +module.exports = Transport;