Files
LangBot/web/tests/unit/websocket-client.test.mjs
T

176 lines
5.0 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import ts from 'typescript';
function load(file, deps = {}, globals = {}) {
const source = fs
.readFileSync(
new URL('../../src/app/infra/websocket/' + file, import.meta.url),
'utf8',
)
.replaceAll('import.meta.env.VITE_API_BASE_URL', 'undefined');
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const m = { exports: {} };
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
(id) => deps[id],
m,
m.exports,
...Object.values(globals),
);
return m.exports;
}
const urls = load('url.ts');
test('OSS and Cloud URLs follow HTTP backend scheme, port and prefix', () => {
for (const [base, page, expected] of [
[undefined, 'http://localhost:3000/home', 'ws://localhost:3000/api/ws'],
[undefined, 'https://cloud.test/home', 'wss://cloud.test/api/ws'],
[
'http://localhost:5300',
'http://localhost:3000',
'ws://localhost:5300/api/ws',
],
['https://api.test/root/', 'https://ui.test', 'wss://api.test/root/api/ws'],
['/backend/', 'https://ui.test/home', 'wss://ui.test/backend/api/ws'],
['https://api.test', 'http://ui.test', 'wss://api.test/api/ws'],
])
assert.equal(
urls.backendWebSocketURL(base, page, '/api/ws').toString(),
expected,
);
});
function setup() {
let workspace = 'one',
next = 0;
const timers = new Map(),
win = new EventTarget(),
doc = new EventTarget();
win.location = { href: 'https://cloud.test/home' };
doc.visibilityState = 'visible';
class WS {
static CONNECTING = 0;
static OPEN = 1;
static instances = [];
readyState = 0;
constructor(url) {
this.url = url;
WS.instances.push(this);
}
send(p) {
this.sent = JSON.parse(p);
}
close() {
this.readyState = 3;
}
closed() {
this.readyState = 3;
this.onclose?.();
}
open() {
this.readyState = 1;
this.onopen?.();
}
receive(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
const { WebSocketClient } = load(
'WebSocketClient.ts',
{
'./url': urls,
'@/app/infra/http/workspaceContext': {
getActiveWorkspaceUuid: () => workspace,
},
},
{
window: win,
document: doc,
WebSocket: WS,
localStorage: { getItem: () => 'token' },
setTimeout: (fn) => {
timers.set(++next, fn);
return next;
},
clearTimeout: (id) => timers.delete(id),
setInterval: () => 1,
clearInterval: () => {},
console: { warn() {}, error() {}, debug() {} },
},
);
return {
client: new WebSocketClient('pipeline'),
WS,
win,
timers,
setWorkspace: (v) => (workspace = v),
};
}
test('only authenticated sockets are connected; disposal removes recovery', async () => {
const { client, WS, win } = setup(),
pending = client.connect(),
ws = WS.instances[0];
ws.open();
assert.equal(client.isConnected(), false);
assert.equal(ws.sent.workspace_uuid, 'one');
ws.receive({ type: 'connected', connection_id: 'id' });
assert.equal(await pending, 'id');
assert(client.isConnected());
client.disconnect();
ws.closed();
win.dispatchEvent(new Event('focus'));
assert.equal(WS.instances.length, 1);
});
test('closed handshakes settle and transient retries survive more than five failures', async () => {
const { client, WS, timers } = setup();
const first = client.connect().catch(() => {});
WS.instances[0].closed();
await first;
for (let i = 0; i < 7; i++) {
const [id, fn] = [...timers][0];
timers.delete(id);
fn();
WS.instances.at(-1).closed();
await Promise.resolve();
}
assert.equal(WS.instances.length, 8);
assert.equal(timers.size, 1);
client.disconnect();
assert.equal(timers.size, 0);
});
test('stale closes do not affect replacement sockets and workspace changes block recovery', async () => {
const { client, WS, win, setWorkspace } = setup();
const pending = client.connect().catch(() => {}),
old = WS.instances[0];
client.disconnect();
const current = client.connect(),
ws = WS.instances[1];
old.closed();
await pending;
ws.open();
ws.receive({ type: 'connected', connection_id: 'new' });
await current;
assert(client.isConnected());
setWorkspace('two');
ws.closed();
win.dispatchEvent(new Event('focus'));
assert.equal(WS.instances.length, 2);
client.disconnect();
});
test('authentication rejection does not reconnect', async () => {
const { client, WS, timers, win } = setup();
const pending = client.connect().catch(() => {}),
ws = WS.instances[0];
ws.open();
ws.receive({ type: 'error', message: 'Unauthorized' });
ws.closed();
await pending;
win.dispatchEvent(new Event('focus'));
assert.equal(timers.size, 0);
assert.equal(WS.instances.length, 1);
client.disconnect();
});