262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { after, before, test } from "node:test";
|
|
import { createServer } from "vite";
|
|
|
|
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
|
|
before(async () => {
|
|
server = await createServer({ appType: "custom", logLevel: "silent",
|
|
server: { middlewareMode: true } });
|
|
({ createIsolatedRerunHost } = await server.ssrLoadModule(
|
|
"/src/components/rerun/isolatedRerunHost.ts"));
|
|
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
|
|
"/src/core/observation/recordedBlueprintLifecycle.ts"));
|
|
({ createRecordedRerunFacade } = await server.ssrLoadModule(
|
|
"/src/components/rerun/recordedRerunFacade.ts"));
|
|
({ createRecordedRerunOwner } = await server.ssrLoadModule(
|
|
"/src/components/rerun/recordedRerunOwner.ts"));
|
|
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
|
|
"/src/components/rerun/isolatedRerunInput.ts"));
|
|
});
|
|
after(async () => { await server?.close(); });
|
|
|
|
function fixture(t, { stopFails = false } = {}) {
|
|
const timers = new Map();
|
|
let timerId = 0, creates = 0, stops = 0, removals = 0;
|
|
t.mock.method(globalThis, "setTimeout", (fn) => {
|
|
timers.set(++timerId, fn); return timerId;
|
|
});
|
|
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
|
|
const listeners = new Map();
|
|
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
|
|
const mount = {};
|
|
const frame = {
|
|
contentWindow: {
|
|
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
|
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
|
removeEventListener: (name) => listeners.delete(name),
|
|
},
|
|
remove() { removals++; },
|
|
};
|
|
const host = { dataset: {}, ownerDocument: { createElement(name) {
|
|
assert.equal(name, "iframe"); return frame;
|
|
} }, append(child) { assert.equal(child, frame); } };
|
|
return { host, frame, viewer, mount, timers, listeners,
|
|
counts: () => ({ creates, stops, removals }) };
|
|
}
|
|
|
|
function bridge(native, mount = {}) {
|
|
const child = createRecordedRerunOwner(() => native, mount);
|
|
const parent = createRecordedRerunFacade((request, bytes) => {
|
|
assert.equal(typeof request, "string");
|
|
const result = child.invoke(request, bytes);
|
|
assert.equal(typeof result, "string");
|
|
return result;
|
|
});
|
|
child.connect(message => {
|
|
assert.equal(typeof message, "string");
|
|
parent.notify(message);
|
|
});
|
|
return parent;
|
|
}
|
|
|
|
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
|
const f = fixture(t, { stopFails: true });
|
|
const scope = createIsolatedRerunHost(f.host);
|
|
assert.equal(f.frame.src, "/rerun-runtime.html");
|
|
f.frame.onload();
|
|
const ready = await scope.ready;
|
|
assert.notEqual(ready.viewer, f.viewer);
|
|
assert.equal(ready.mount, f.host);
|
|
assert.equal(f.timers.size, 0);
|
|
assert.equal(f.listeners.size, 1);
|
|
scope.dispose(); scope.dispose();
|
|
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
|
|
assert.equal(f.listeners.size, 0);
|
|
assert.equal(f.frame.onload, null);
|
|
assert.equal(ready.viewer.ready, false);
|
|
});
|
|
|
|
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
|
|
const listeners = new Map(), events = [];
|
|
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
|
|
const host = { dataset: {}, ownerDocument: { defaultView: {
|
|
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
|
|
} }, dispatchEvent: event => events.push(event) };
|
|
const child = {
|
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
|
removeEventListener: name => listeners.delete(name),
|
|
};
|
|
const release = relayIsolatedRerunInput(host, child, null);
|
|
assert.equal(listeners.has("pointerdown"), false);
|
|
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
|
|
assert.equal(events[0].key, "Escape");
|
|
release(); release();
|
|
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
|
|
});
|
|
|
|
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
|
|
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
|
|
const range = { min: 1, max: 2 }, mount = {};
|
|
const native = {
|
|
ready: true, stop() {},
|
|
async start(source, parent) { assert.equal(parent, mount); starts++; },
|
|
get_time_range: () => range,
|
|
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
|
|
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
|
|
};
|
|
const { facade, dispose } = bridge(native, mount);
|
|
await facade.start(null, {}, null);
|
|
assert.equal(starts, 1);
|
|
let event;
|
|
const unsubscribe = facade.on("time_update", value => { event = value; });
|
|
eventCallback(range);
|
|
assert.deepEqual(event, range); assert.notEqual(event, range);
|
|
assert.notEqual(facade.get_time_range(), range);
|
|
const channel = facade.open_channel();
|
|
channel.send_rrd(new Uint8Array());
|
|
dispose(); dispose(); unsubscribe();
|
|
assert.equal(unsubscribed, 1);
|
|
assert.equal(channel.ready, false);
|
|
channel.send_rrd(new Uint8Array());
|
|
eventCallback({ min: 3, max: 4 });
|
|
assert.deepEqual(event, range);
|
|
assert.equal(sends, 1);
|
|
await assert.rejects(facade.start(null, {}, null), /disposed/);
|
|
});
|
|
|
|
test("close before frame load cancels startup and cannot reopen", async (t) => {
|
|
const f = fixture(t);
|
|
const scope = createIsolatedRerunHost(f.host);
|
|
const lateLoad = f.frame.onload;
|
|
const rejected = assert.rejects(scope.ready, /disposed/);
|
|
scope.dispose(); lateLoad();
|
|
await rejected;
|
|
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
|
|
assert.equal(f.timers.size, 0);
|
|
});
|
|
|
|
test("closing during SDK startup also releases a late successful start", async () => {
|
|
let finish, stops = 0;
|
|
const owner = bridge({
|
|
start: () => new Promise(resolve => { finish = resolve; }),
|
|
stop() { stops++; },
|
|
}, {});
|
|
const pending = owner.facade.start(null, {}, null);
|
|
const rejected = assert.rejects(pending, /disposed/);
|
|
owner.dispose();
|
|
assert.equal(stops, 1);
|
|
finish();
|
|
await rejected;
|
|
await Promise.resolve();
|
|
assert.equal(stops, 2);
|
|
assert.equal(owner.facade.ready, false);
|
|
});
|
|
|
|
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
|
|
let fail, stops = 0;
|
|
const owner = bridge({
|
|
start: () => new Promise((_resolve, reject) => { fail = reject; }),
|
|
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
|
|
}, {});
|
|
const pending = owner.facade.start(null, {}, null);
|
|
const rejected = assert.rejects(pending, /disposed/);
|
|
owner.dispose();
|
|
fail(new Error("startup failed"));
|
|
await rejected;
|
|
await Promise.resolve();
|
|
assert.equal(stops, 2);
|
|
assert.equal(owner.facade.ready, false);
|
|
});
|
|
|
|
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
|
|
const calls = [];
|
|
const methods = {
|
|
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
|
|
override_panel_state: ["time", "hidden"],
|
|
get_active_recording_id: [], get_active_timeline: ["recording"],
|
|
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
|
|
get_time_range: ["recording", "session_time"],
|
|
set_active_timeline: ["recording", "session_time"],
|
|
set_current_time: ["recording", "session_time", 123.456],
|
|
set_playing: ["recording", false],
|
|
};
|
|
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
|
|
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
|
|
const { facade, dispose } = bridge(native);
|
|
for (const [name, args] of Object.entries(methods)) facade[name](...args);
|
|
assert.deepEqual(calls, Object.entries(methods));
|
|
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
|
|
dispose();
|
|
});
|
|
|
|
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
|
|
const nativeError = new Error("native failure");
|
|
let channelName = "unobserved";
|
|
const { facade, dispose } = bridge({
|
|
stop() {},
|
|
open() { throw nativeError; },
|
|
open_channel(name) { channelName = name; return { close() {} }; },
|
|
});
|
|
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
|
|
facade.open_channel();
|
|
assert.equal(channelName, undefined);
|
|
dispose();
|
|
});
|
|
|
|
test("dispose closes every auxiliary channel even if one channel fails", () => {
|
|
const closed = [];
|
|
let stops = 0;
|
|
const owner = bridge({
|
|
stop() { stops++; },
|
|
open_channel: name => ({ close() {
|
|
closed.push(name);
|
|
if (name === "first") throw new Error("already gone");
|
|
} }),
|
|
}, {});
|
|
const first = owner.facade.open_channel("first");
|
|
const second = owner.facade.open_channel("second");
|
|
owner.dispose(); owner.dispose(); first.close(); second.close();
|
|
assert.deepEqual(closed, ["first", "second"]);
|
|
assert.equal(stops, 1);
|
|
});
|
|
|
|
test("host load timeout removes the frame and clears its timer", async (t) => {
|
|
const f = fixture(t);
|
|
const scope = createIsolatedRerunHost(f.host);
|
|
const rejected = assert.rejects(scope.ready, /timed out/);
|
|
[...f.timers.values()][0]();
|
|
await rejected;
|
|
assert.equal(f.counts().removals, 1);
|
|
assert.equal(f.timers.size, 0);
|
|
});
|
|
|
|
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
|
|
let tick, canceled = 0;
|
|
const requests = [];
|
|
const release = keepRecordedBlueprintSession({
|
|
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
|
|
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
|
|
recordingId: "recording", ownerId: "a".repeat(32),
|
|
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
|
|
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
|
|
});
|
|
tick();
|
|
const renewal = requests[0];
|
|
release(); release(); tick();
|
|
assert.equal(canceled, 1);
|
|
assert.equal(requests.length, 2);
|
|
assert.equal(renewal.signal.aborted, true);
|
|
assert.equal(requests[1].keepalive, true);
|
|
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
|
|
assert.deepEqual(JSON.parse(requests[1].body), {
|
|
action: "release", application_id: "nodedc_mission_core_recorded",
|
|
recording_id: "recording", blueprint_session_id: "a".repeat(32),
|
|
});
|
|
});
|
|
|
|
test("blueprint lease refuses a foreign endpoint", () => {
|
|
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
|
|
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
|
|
}), /Unsafe/);
|
|
});
|