lct-hack/frontend/scripts/test-ws-outbox.mjs

137 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { transform } from "esbuild";
const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8");
const { code } = await transform(source, { loader: "ts", format: "esm" });
const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
const { callChannel, randomUuid, stationChannel } = await import(moduleUrl);
const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8");
const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" });
const { applyPatch, applyState, edit, empty } = await import(
`data:text/javascript;base64,${Buffer.from(mergeCode).toString("base64")}`,
);
class FakeWebSocket {
static OPEN = 1;
static instances = [];
readyState = 0;
sent = [];
constructor(url) {
this.url = url;
FakeWebSocket.instances.push(this);
}
send(value) { this.sent.push(JSON.parse(value)); }
open() { this.readyState = FakeWebSocket.OPEN; this.onopen(); }
drop() { this.readyState = 3; this.onclose(); }
receive(event) { this.onmessage({ data: JSON.stringify(event) }); }
}
globalThis.WebSocket = FakeWebSocket;
globalThis.location = { protocol: "http:", host: "localhost" };
const reconnectTimers = [];
globalThis.setTimeout = (callback) => (reconnectTimers.push(callback), reconnectTimers.length);
globalThis.clearTimeout = () => {};
function reconnect(socket) {
socket.drop();
reconnectTimers.shift()();
const next = FakeWebSocket.instances.at(-1);
next.open();
return next;
}
const call = callChannel("session-1").connect();
let socket = FakeWebSocket.instances.at(-1);
socket.open();
const patch = {
type: "kio.patch",
fields: {
address: "ул. Тестовая, 1",
notify: ["101", "СМП"],
coords: { latitude: 55.7, longitude: 37.6 },
},
};
assert.equal(call.send(patch), true);
const sentPatch = socket.sent[0];
assert.equal(sentPatch.type, patch.type);
assert.deepEqual(sentPatch.fields, patch.fields);
assert.equal(typeof sentPatch._command_id, "string");
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "unacknowledged KIO patch is replayed after reconnect");
socket.receive({ type: "kio.patch", fields: patch.fields, source: "auto" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "unrelated automatic patch does not acknowledge operator input");
socket.receive({ type: "kio.patch", fields: { address: "ул. Другая, 2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentPatch], "different operator state does not acknowledge the pending patch");
socket.receive({ type: "kio.patch", fields: {
address: patch.fields.address,
notify: ["101", "СМП"],
coords: { longitude: 37.6, latitude: 55.7 },
}, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
call.send(refused);
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
socket.sent = [];
call.send({ type: "kio.patch", fields: { floor: "2" } });
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
const dds = stationChannel("session-1", "dds").connect();
socket = FakeWebSocket.instances.at(-1);
socket.open();
const crewSelect = { type: "crew.select", crew: "ПСЧ-1" };
assert.equal(dds.send(crewSelect), true);
const sentCrewSelect = socket.sent[0];
assert.equal(sentCrewSelect.type, crewSelect.type);
assert.equal(sentCrewSelect.crew, crewSelect.crew);
assert.equal(typeof sentCrewSelect._command_id, "string");
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentCrewSelect], "station command is replayed with its stable ID");
socket.receive({ type: "command.ack", command_id: sentCrewSelect._command_id });
socket.sent = [];
const reply = { type: "card.reply", card_id: "card-1", text: "Принято" };
assert.equal(dds.send(reply), true);
const sentReply = socket.sent[0];
assert.equal(sentReply.type, reply.type);
assert.equal(sentReply.card_id, reply.card_id);
assert.equal(sentReply.text, reply.text);
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentReply], "unacknowledged reply is replayed after reconnect");
socket.receive({ type: "station.state", snapshot: { card_id: "card-2", reply_text: "Принято" } });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [sentReply], "reply for another card does not acknowledge this card");
socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_text: "Принято" } });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply");
// Стенд по http://<IP> — не защищённый контекст: randomUUID у браузера нет.
const nativeRandomUuid = Object.getOwnPropertyDescriptor(globalThis.crypto, "randomUUID");
Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true });
try {
const ids = Array.from({ length: 200 }, () => randomUuid());
for (const id of ids) {
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, "UUID v4 without randomUUID");
}
assert.equal(new Set(ids).size, ids.length, "UUIDs without randomUUID do not repeat");
} finally {
if (nativeRandomUuid) Object.defineProperty(globalThis.crypto, "randomUUID", nativeRandomUuid);
else delete globalThis.crypto.randomUUID;
}
assert.match(randomUuid(), /^[0-9a-f-]{36}$/, "native randomUUID is used when present");
console.log("WebSocket durable outbox replay/ack tests passed");