159 lines
4.3 KiB
JavaScript
159 lines
4.3 KiB
JavaScript
// jsdom harness for the browser client tests. It loads the real
|
|
// client/index.html, wires jQuery to the simulated window and exposes the
|
|
// globals the client modules expect ($, window, document, requestAnimationFrame,
|
|
// a fake WebSocket). Everything is restored on teardown so the Node server
|
|
// tests keep using the real WebSocket.
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { JSDOM } from "jsdom";
|
|
|
|
const INDEX_PATH = fileURLToPath(new URL("../../client/index.html", import.meta.url));
|
|
const JQUERY_PATH = fileURLToPath(new URL("../../client/vendor/jquery.min.js", import.meta.url));
|
|
// Only the globals the client actually reads. Some Node globals (navigator,
|
|
// location) are getter-only, so they are left alone.
|
|
const MANAGED_GLOBALS = [
|
|
"window", "document", "$", "jQuery", "WebSocket", "Audio",
|
|
"requestAnimationFrame", "cancelAnimationFrame",
|
|
];
|
|
|
|
// Minimal stand-in for the browser WebSocket used by Net. Tests drive it by
|
|
// calling simulateOpen/simulateMessage/simulateClose.
|
|
export class FakeWebSocket {
|
|
static CONNECTING = 0;
|
|
static OPEN = 1;
|
|
static CLOSING = 2;
|
|
static CLOSED = 3;
|
|
static instances = [];
|
|
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = FakeWebSocket.CONNECTING;
|
|
this.sent = [];
|
|
FakeWebSocket.instances.push(this);
|
|
}
|
|
|
|
send(data) {
|
|
this.sent.push(JSON.parse(data));
|
|
}
|
|
|
|
close() {
|
|
if (this.readyState === FakeWebSocket.CLOSED) return;
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
if (this.onclose) this.onclose({});
|
|
}
|
|
|
|
simulateOpen() {
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
if (this.onopen) this.onopen({});
|
|
}
|
|
|
|
simulateMessage(message) {
|
|
if (this.onmessage) this.onmessage({ data: JSON.stringify(message) });
|
|
}
|
|
|
|
simulateError() {
|
|
if (this.onerror) this.onerror({});
|
|
}
|
|
|
|
simulateClose() {
|
|
this.close();
|
|
}
|
|
|
|
lastSent() {
|
|
return this.sent[this.sent.length - 1];
|
|
}
|
|
|
|
static reset() {
|
|
FakeWebSocket.instances = [];
|
|
}
|
|
|
|
static get last() {
|
|
return FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
|
}
|
|
}
|
|
|
|
// Stand-in for the browser Audio element. jsdom's play() is unimplemented and
|
|
// noisy, and the tests only care that the right source was asked to play.
|
|
export class FakeAudio {
|
|
static instances = [];
|
|
|
|
static reset() {
|
|
FakeAudio.instances = [];
|
|
}
|
|
|
|
// Every source that was actually played, in order.
|
|
static get playedSources() {
|
|
return FakeAudio.instances.filter((audio) => audio.plays > 0).map((audio) => audio.src);
|
|
}
|
|
|
|
constructor(src) {
|
|
this.src = src;
|
|
this.volume = 1;
|
|
this.loop = false;
|
|
this.currentTime = 0;
|
|
this.paused = true;
|
|
this.plays = 0;
|
|
FakeAudio.instances.push(this);
|
|
}
|
|
|
|
play() {
|
|
this.plays += 1;
|
|
this.paused = false;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
pause() {
|
|
this.paused = true;
|
|
}
|
|
}
|
|
|
|
export async function setupDom(options = {}) {
|
|
const html = await readFile(INDEX_PATH, "utf8");
|
|
const jquerySource = await readFile(JQUERY_PATH, "utf8");
|
|
const dom = new JSDOM(html, {
|
|
url: "http://localhost/client/index.html",
|
|
runScripts: "outside-only",
|
|
pretendToBeVisual: false,
|
|
});
|
|
const window = dom.window;
|
|
// Evaluate the exact jQuery file the client serves into the simulated window.
|
|
window.eval(jquerySource);
|
|
const $ = window.jQuery;
|
|
|
|
const saved = new Map();
|
|
const install = (name, value) => {
|
|
saved.set(name, Object.getOwnPropertyDescriptor(globalThis, name) || null);
|
|
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
|
};
|
|
install("window", window);
|
|
install("document", window.document);
|
|
install("$", $);
|
|
install("jQuery", $);
|
|
install("WebSocket", options.webSocket || FakeWebSocket);
|
|
install("Audio", options.audio || FakeAudio);
|
|
// Keep the render loop out of tests: enter() schedules a frame but the stub
|
|
// never runs it, so no timer keeps the process alive.
|
|
install("requestAnimationFrame", () => 0);
|
|
install("cancelAnimationFrame", () => {});
|
|
|
|
return { dom, window, $, saved };
|
|
}
|
|
|
|
export function teardownDom(env) {
|
|
if (!env) return;
|
|
for (const [name, descriptor] of env.saved) {
|
|
try {
|
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
|
else delete globalThis[name];
|
|
} catch {
|
|
// leave a non-configurable global as it is
|
|
}
|
|
}
|
|
try {
|
|
env.dom.window.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|