local createLibTest = require('/apis/libtest'); local createSandbox = require('/apis/libsandbox'); local fakes = require('/tests/harness/fake-eventloop'); local testlib = createLibTest({ ... }); local fakeOs = fakes.fakeOs; local encode = textutils.serializeJSON; -- traposId ---------------------------------------------------------------- testlib.test('traposId joins id and label', function() local sandbox = createSandbox(); testlib.assertEquals(sandbox.traposId(fakeOs(7, 'base')), '7:base'); end); testlib.test('traposId renders empty/nil label as trailing colon', function() local sandbox = createSandbox(); testlib.assertEquals(sandbox.traposId(fakeOs(7, nil)), '7:'); testlib.assertEquals(sandbox.traposId(fakeOs(8, '')), '8:'); end); -- uuid -------------------------------------------------------------------- testlib.test('uuid emits a v4-shaped string', function() local sandbox = createSandbox(); local id = sandbox.uuid(); testlib.assertEquals(string.len(id), 36); testlib.assertEquals(string.sub(id, 15, 15), '4'); testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil); end); -- envelope builders ------------------------------------------------------- testlib.test('buildComputerRequest carries sender id and omits empty payload', function() local sandbox = createSandbox(); local frame = sandbox.buildComputerRequest('7:base', 'mid-1', 'probe_gateway', nil); testlib.assertEquals(frame.event, 'computer_request'); testlib.assertEquals(frame.traposSenderId, '7:base'); testlib.assertEquals(frame.messageId, 'mid-1'); testlib.assertEquals(frame.type, 'probe_gateway'); testlib.assertEquals(frame.payload, nil); local withPayload = sandbox.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 }); testlib.assertEquals(withPayload.payload.a, 1); end); testlib.test('buildHello includes secret only when non-empty', function() local sandbox = createSandbox(); local withSecret = sandbox.buildHello('7:base', 'hunter2', 'mid'); testlib.assertEquals(withSecret.event, 'computer_request'); testlib.assertEquals(withSecret.type, 'hello'); testlib.assertEquals(withSecret.traposSenderId, '7:base'); testlib.assertEquals(withSecret.payload.traposId, '7:base'); testlib.assertEquals(withSecret.payload.secret, 'hunter2'); local noSecret = sandbox.buildHello('7:base', nil, 'mid'); testlib.assertEquals(noSecret.payload.secret, nil); local emptySecret = sandbox.buildHello('7:base', '', 'mid'); testlib.assertEquals(emptySecret.payload.secret, nil); end); -- onMessage dispatch ------------------------------------------------------ testlib.test('onMessage replies to a reverse ping', function() local sandbox = createSandbox(); local sent = {}; sandbox.onMessage(encode({ event = 'gateway_request', type = 'ping', messageId = 'g-1' }), { traposId = '7:base', send = function(frame) sent[#sent + 1] = frame; end, }); testlib.assertEquals(#sent, 1); testlib.assertEquals(sent[1].event, 'computer_response'); testlib.assertEquals(sent[1].type, 'ping'); testlib.assertEquals(sent[1].ok, true); testlib.assertEquals(sent[1].messageId, 'g-1'); testlib.assertEquals(sent[1].traposSenderId, '7:base'); testlib.assertEquals(sent[1].payload.traposId, '7:base'); end); testlib.test('onMessage rejects unknown reverse type with unknown_type', function() local sandbox = createSandbox(); local sent = {}; sandbox.onMessage(encode({ event = 'gateway_request', type = 'dance', messageId = 'g-2' }), { traposId = '7:base', send = function(frame) sent[#sent + 1] = frame; end, }); testlib.assertEquals(#sent, 1); testlib.assertEquals(sent[1].ok, false); testlib.assertEquals(sent[1].error.code, 'unknown_type'); end); testlib.test('onMessage drives hello via onHello', function() local sandbox = createSandbox(); local calls = {}; sandbox.onMessage(encode({ event = 'gateway_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), { onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end, }); testlib.assertEquals(#calls, 1); testlib.assertEquals(calls[1].ok, true); testlib.assertEquals(calls[1].payload.accountId, 'local'); end); testlib.test('onMessage queues a reply for a non-hello gateway_response', function() local sandbox = createSandbox(); local queued = {}; sandbox.onMessage(encode({ event = 'gateway_response', type = 'probe_gateway', ok = true, messageId = 'mid-9', payload = { sandboxOk = true }, }), { queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, }); testlib.assertEquals(#queued, 1); testlib.assertEquals(queued[1][1], 'trapos_sandbox_reply'); testlib.assertEquals(queued[1][2], 'mid-9'); testlib.assertEquals(queued[1][3], true); testlib.assertEquals(queued[1][4].sandboxOk, true); end); testlib.test('onMessage coerces a missing payload to an empty table', function() local sandbox = createSandbox(); local queued = {}; sandbox.onMessage(encode({ event = 'gateway_response', type = 'noop', ok = true, messageId = 'mid-x' }), { queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, }); testlib.assertEquals(#queued, 1); testlib.assertEquals(type(queued[1][4]), 'table'); testlib.assertEquals(next(queued[1][4]), nil); end); testlib.test('onMessage stays silent on malformed frames', function() local sandbox = createSandbox(); local sent = 0; local ctx = { traposId = '7:base', send = function() sent = sent + 1; end, queueEvent = function() sent = sent + 1; end, onHello = function() sent = sent + 1; end, }; sandbox.onMessage('not json', ctx); sandbox.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx); testlib.assertEquals(sent, 0); end); -- startSession wiring ----------------------------------------------------- local function startSession(extra) local sandbox = createSandbox(); local el = fakes.fakeEventloop(); local http = fakes.fakeHttp(); local queued = {}; local opts = { eventloop = el, http = http, url = 'ws://host:4444', os = fakeOs(7, 'base'), queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, }; if extra then for k, v in pairs(extra) do opts[k] = v; end end local session = sandbox.startSession(opts); return { sandbox = sandbox, el = el, http = http, queued = queued, session = session, url = http.connects[1] }; end testlib.test('startSession connects to the /gateway path', function() local ctx = startSession(); testlib.assertEquals(ctx.url, 'ws://host:4444/gateway'); end); testlib.test('startSession sends hello on open and goes ready on ack', function() local ctx = startSession({ secret = 'hunter2' }); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); testlib.assertEquals(ctx.session.isReady(), false); testlib.assertEquals(#ws.sent, 1); local hello = textutils.unserializeJSON(ws.sent[1]); testlib.assertEquals(hello.type, 'hello'); testlib.assertEquals(hello.payload.secret, 'hunter2'); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); local connected = ctx.queued[#ctx.queued]; testlib.assertEquals(connected[1], 'trapos_sandbox_connected'); testlib.assertEquals(connected[2], '7:base'); end); testlib.test('startSession treats unauthorized hello as terminal', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); local timersAfterOpen = #ctx.el.timers; -- hello watchdog ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' }, })); testlib.assertEquals(ctx.session.isReady(), false); testlib.assertEquals(ctx.session.lastError(), 'unauthorized'); testlib.assertEquals(ws.closed, true); -- A subsequent close must NOT schedule a reconnect. ctx.el.fire('websocket_closed', ctx.url); testlib.assertEquals(#ctx.el.timers, timersAfterOpen); end); testlib.test('startSession reconnects on a network drop', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); testlib.assertEquals(#ctx.http.connects, 1); ctx.el.fire('websocket_closed', ctx.url); ctx.el.fireTimer(#ctx.el.timers); -- the reconnect timer testlib.assertEquals(#ctx.http.connects, 2); end); testlib.test('startSession reconnects on an abnormal close with a non-displaced code', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); testlib.assertEquals(#ctx.http.connects, 1); ctx.el.fire('websocket_closed', ctx.url, '', 1006); -- abnormal close, not a displacement ctx.el.fireTimer(#ctx.el.timers); testlib.assertEquals(#ctx.http.connects, 2); end); testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function() local logs = {}; local function countMsg(msg) local n = 0; for _, entry in ipairs(logs) do if entry.msg == msg then n = n + 1; end end return n; end local ctx = startSession({ log = function(level, msg, fields) logs[#logs + 1] = { level = level, msg = msg, fields = fields }; end, }); -- Healthy connect: no reconnect-loop logs yet. ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs()); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(countMsg('reconnect loop started'), 0); -- Drop, then fail the retry several times: the loop is announced exactly once and the -- per-attempt chatter (connecting / connection failed) does not repeat while looping. ctx.el.fire('websocket_closed', ctx.url); local connectingBefore = countMsg('connecting'); for _ = 1, 3 do ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect() ctx.el.fire('websocket_failure', ctx.url); -- retry fails, schedules the next timer end testlib.assertEquals(countMsg('reconnect loop started'), 1); testlib.assertEquals(countMsg('connection closed'), 1); -- the first drop logged, not the retries testlib.assertEquals(countMsg('connecting'), connectingBefore); -- suppressed while looping testlib.assertEquals(countMsg('connection failed'), 0); -- suppressed while looping -- Recovery: a single "reconnected" line, no second "loop started". ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect() ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs()); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1); testlib.assertEquals(countMsg('reconnect loop started'), 1); end); testlib.test('startSession treats a displacement close (4000) as terminal', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); local timersBefore = #ctx.el.timers; ctx.el.fire('websocket_closed', ctx.url, 'displaced', 4000); testlib.assertEquals(ctx.session.isReady(), false); testlib.assertEquals(ctx.session.lastError(), 'displaced'); -- No reconnect timer scheduled, and no further connect attempt even if one fired. testlib.assertEquals(#ctx.el.timers, timersBefore); testlib.assertEquals(#ctx.http.connects, 1); end); testlib.test('startSession treats a displacement close by reason as terminal', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); local timersBefore = #ctx.el.timers; ctx.el.fire('websocket_closed', ctx.url, 'displaced'); -- reason only, code absent testlib.assertEquals(ctx.session.lastError(), 'displaced'); testlib.assertEquals(#ctx.el.timers, timersBefore); testlib.assertEquals(#ctx.http.connects, 1); end); testlib.test('startSession ignores a displacement of an already-superseded socket', function() -- A 4000 close that arrives before our reconnect completes hello is for a socket -- our own reconnect superseded; it must not brick the daemon into 'displaced'. local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); -- still awaiting_hello (no ack yet): a stale displacement arrives. ctx.el.fire('websocket_closed', ctx.url, 'displaced', 4000); testlib.assertEquals(ctx.session.lastError(), nil); -- Not terminal: a hello ack on the live socket still brings us to ready. ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); testlib.assertEquals(ctx.session.isReady(), true); end); testlib.test('hello watchdog closes a socket stuck awaiting ack', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fireTimer(#ctx.el.timers); -- watchdog fires while still awaiting_hello testlib.assertEquals(ws.closed, true); end); testlib.test('send handler replies not_connected when not ready', function() local ctx = startSession(); ctx.el.fire('trapos_sandbox_send', 'mid-1', 'probe_gateway', nil); local reply = ctx.queued[#ctx.queued]; testlib.assertEquals(reply[1], 'trapos_sandbox_reply'); testlib.assertEquals(reply[2], 'mid-1'); testlib.assertEquals(reply[3], false); testlib.assertEquals(reply[5].code, 'not_connected'); end); testlib.test('send handler forwards a request when ready without an immediate reply', function() local ctx = startSession(); local ws = fakes.fakeWs(); ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); local queuedBefore = #ctx.queued; ctx.el.fire('trapos_sandbox_send', 'mid-2', 'probe_gateway', nil); testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply local frame = textutils.unserializeJSON(ws.sent[#ws.sent]); testlib.assertEquals(frame.event, 'computer_request'); testlib.assertEquals(frame.type, 'probe_gateway'); testlib.assertEquals(frame.messageId, 'mid-2'); end); -- request() --------------------------------------------------------------- testlib.test('request resolves on a matching reply', function() local sandbox = createSandbox(); local events = fakes.fakeEvents({ { 'trapos_sandbox_reply', 'mid', true, { sandboxOk = true }, n = 4 }, }); local ok, payload = sandbox.request('probe_gateway', nil, { messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, startTimer = events.startTimer, cancelTimer = events.cancelTimer, }); testlib.assertEquals(ok, true); testlib.assertEquals(payload.sandboxOk, true); testlib.assertEquals(events.cancelled[1], 'timer-1'); end); testlib.test('request returns the error on a failed reply', function() local sandbox = createSandbox(); local events = fakes.fakeEvents({ { 'trapos_sandbox_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 }, }); local ok, err = sandbox.request('probe_gateway', nil, { messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, startTimer = events.startTimer, cancelTimer = events.cancelTimer, }); testlib.assertEquals(ok, false); testlib.assertEquals(err.code, 'unauthorized'); end); testlib.test('request re-queues a mismatched reply and keeps waiting', function() local sandbox = createSandbox(); local events = fakes.fakeEvents({ { 'trapos_sandbox_reply', 'other', true, { y = 2 }, n = 4 }, { 'trapos_sandbox_reply', 'mid', true, { x = 1 }, n = 4 }, }); local ok, payload = sandbox.request('probe_gateway', nil, { messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, startTimer = events.startTimer, cancelTimer = events.cancelTimer, }); testlib.assertEquals(ok, true); testlib.assertEquals(payload.x, 1); -- queued[1] is the trapos_sandbox_send; queued[2] is the re-queued reply. testlib.assertEquals(events.queued[2][1], 'trapos_sandbox_reply'); testlib.assertEquals(events.queued[2][2], 'other'); end); testlib.test('request times out when the timer fires first', function() local sandbox = createSandbox(); local events = fakes.fakeEvents({ { 'timer', 'timer-1', n = 2 }, }); local ok, err = sandbox.request('probe_gateway', nil, { messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, startTimer = events.startTimer, cancelTimer = events.cancelTimer, }); testlib.assertEquals(ok, false); testlib.assertEquals(err.code, 'timeout'); end); testlib.run();