563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
type OfflineSnapshot,
|
|
resolveOffline,
|
|
serverNowFrom,
|
|
snapshotFrom,
|
|
} from "../../src/browser/offline.js";
|
|
import type { SceneSelection } from "../../src/browser/scene-request.js";
|
|
import {
|
|
createMemoryMediaCache,
|
|
createMemoryStore,
|
|
signageMediaKey,
|
|
} from "../../src/browser/storage.js";
|
|
import {
|
|
createDisplayController,
|
|
type DisplayChannelStateView,
|
|
} from "../../src/display/controller.js";
|
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
|
|
|
/**
|
|
* Offline continuation: what a screen does when its building's uplink dies.
|
|
*
|
|
* Every case here is one an unattended television actually meets — a router reboot during a service,
|
|
* a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is
|
|
* always the same one: the display continues *deterministically from the last authoritative anchor*,
|
|
* replaying only what the server already resolved, and never becomes a second authority.
|
|
*/
|
|
|
|
const SCENE = {
|
|
backgroundColor: "#000000",
|
|
backgroundImage: false,
|
|
backgroundMode: "none" as const,
|
|
blurPixels: 0,
|
|
darkenOpacity: 0,
|
|
fit: "fill" as const,
|
|
shadow: false,
|
|
};
|
|
|
|
function selection(overrides: Partial<SceneSelection> & { id: string }): SceneSelection {
|
|
return {
|
|
availableUntilMs: null,
|
|
deadlineAtMs: null,
|
|
durationMs: 5000,
|
|
itemId: `item-${overrides.id}`,
|
|
itemRevision: "rev-1",
|
|
payload: { kind: "native-image" },
|
|
presentation: null,
|
|
providerId: "signage.native-images",
|
|
renderer: { kind: "native-image" },
|
|
revision: 1,
|
|
scene: SCENE,
|
|
slotId: null,
|
|
startedAtMs: null,
|
|
withdrawn: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function slot(id: string, position: number, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
eligibleFromMs: null,
|
|
eligibleUntilMs: null,
|
|
enabled: true,
|
|
estimatedDurationMs: 5000,
|
|
id,
|
|
position,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function snapshot(overrides: Partial<OfflineSnapshot> = {}): OfflineSnapshot {
|
|
return snapshotFrom({
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
localNowMs: 1_000_000,
|
|
playback: {
|
|
blackout: false,
|
|
currentSelectionId: "sel-a",
|
|
currentSlotId: "slot-a",
|
|
deadlineAtMs: 5000,
|
|
pendingTimelineId: null,
|
|
remainingMs: 5000,
|
|
revision: 3,
|
|
startedAtMs: 0,
|
|
transport: "playing",
|
|
...overrides.playback,
|
|
},
|
|
selections: overrides.selections ?? [
|
|
selection({ id: "sel-c", slotId: "slot-c" }),
|
|
selection({ id: "sel-b", slotId: "slot-b" }),
|
|
selection({ id: "sel-a", slotId: "slot-a" }),
|
|
],
|
|
serverNowMs: 0,
|
|
staticHold: overrides.staticHold ?? null,
|
|
timeline:
|
|
overrides.timeline === undefined
|
|
? {
|
|
eligibleFromMs: null,
|
|
eligibleUntilMs: null,
|
|
id: "timeline-1",
|
|
revision: 2,
|
|
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
|
|
}
|
|
: overrides.timeline,
|
|
});
|
|
}
|
|
|
|
describe("offline continuation", () => {
|
|
it("reconstructs the server's clock, however wrong the device's is", () => {
|
|
const persisted = snapshot();
|
|
|
|
// The device's clock jumped forward two hours while the screen was off. The anchor is what makes
|
|
// the evaluation right anyway: only the *elapsed* local time matters.
|
|
expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000);
|
|
expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000);
|
|
});
|
|
|
|
it("continues to the next slot at the deadline, using what the server already resolved", () => {
|
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() });
|
|
|
|
expect(evaluation.resolution).toMatchObject({
|
|
kind: "selection",
|
|
startedAtMs: 5000,
|
|
});
|
|
if (evaluation.resolution.kind !== "selection") {
|
|
throw new Error("Expected a selection.");
|
|
}
|
|
// The next slot in the pattern, rendered from its own cached answer. Nothing was invented.
|
|
expect(evaluation.resolution.selection.id).toBe("sel-b");
|
|
expect(evaluation.nextBoundaryMs).toBe(10_000);
|
|
});
|
|
|
|
it("catches up a week in one evaluation rather than replaying every slide", () => {
|
|
const week = 7 * 24 * 60 * 60 * 1000;
|
|
const started = Date.now();
|
|
|
|
const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() });
|
|
|
|
expect(evaluation.resolution.kind).toBe("selection");
|
|
if (evaluation.resolution.kind !== "selection") {
|
|
throw new Error("Expected a selection.");
|
|
}
|
|
// Landed inside the current slot, on the server's own phase — and the arithmetic took no
|
|
// measurable time, because it is arithmetic.
|
|
expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week);
|
|
expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000);
|
|
expect(Date.now() - started).toBeLessThan(200);
|
|
});
|
|
|
|
it("stays exactly where it was while paused, with its remainder preserved", () => {
|
|
const paused = snapshot({
|
|
playback: {
|
|
blackout: false,
|
|
currentSelectionId: "sel-a",
|
|
currentSlotId: "slot-a",
|
|
deadlineAtMs: null,
|
|
pendingTimelineId: null,
|
|
remainingMs: 3000,
|
|
revision: 4,
|
|
startedAtMs: null,
|
|
transport: "paused",
|
|
},
|
|
});
|
|
|
|
const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused });
|
|
|
|
// Ten minutes later, on the same slide. Frozen means frozen, offline as much as online.
|
|
expect(evaluation.resolution).toMatchObject({ kind: "paused" });
|
|
if (evaluation.resolution.kind !== "paused") {
|
|
throw new Error("Expected a paused resolution.");
|
|
}
|
|
expect(evaluation.resolution.selection?.id).toBe("sel-a");
|
|
expect(paused.playback.remainingMs).toBe(3000);
|
|
});
|
|
|
|
it("keeps a static hold held, and does not let local age release it", () => {
|
|
const held = snapshot({ staticHold: selection({ id: "static-1" }) });
|
|
|
|
const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held });
|
|
|
|
expect(evaluation.resolution).toMatchObject({ kind: "static" });
|
|
});
|
|
|
|
it("releases a static hold only when the held image genuinely expired", () => {
|
|
const held = snapshot({
|
|
staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }),
|
|
});
|
|
|
|
const before = resolveOffline({ atMs: 9000, snapshot: held });
|
|
const after = resolveOffline({ atMs: 11_000, snapshot: held });
|
|
|
|
expect(before.resolution.kind).toBe("static");
|
|
// An explicit expiry the server told it about — never a guess made locally.
|
|
expect(after.resolution.kind).toBe("selection");
|
|
});
|
|
|
|
it("skips content the server said was withdrawn or expired", () => {
|
|
const persisted = snapshot({
|
|
selections: [
|
|
selection({ id: "sel-c", slotId: "slot-c" }),
|
|
selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }),
|
|
selection({ id: "sel-a", slotId: "slot-a" }),
|
|
],
|
|
});
|
|
|
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
|
|
|
// Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an
|
|
// empty provider slot — no gap, no error, and certainly no fabricated replacement.
|
|
expect(evaluation.resolution).toMatchObject({ kind: "selection" });
|
|
if (evaluation.resolution.kind !== "selection") {
|
|
throw new Error("Expected a selection.");
|
|
}
|
|
expect(evaluation.resolution.selection.id).toBe("sel-c");
|
|
});
|
|
|
|
it("holds the last good frame rather than continuing into a timeline it never received", () => {
|
|
const pending = snapshot({
|
|
playback: {
|
|
blackout: false,
|
|
currentSelectionId: "sel-a",
|
|
currentSlotId: "slot-a",
|
|
deadlineAtMs: 5000,
|
|
pendingTimelineId: "timeline-2",
|
|
remainingMs: 0,
|
|
revision: 5,
|
|
startedAtMs: 0,
|
|
transport: "playing",
|
|
},
|
|
});
|
|
|
|
const evaluation = resolveOffline({ atMs: 6000, snapshot: pending });
|
|
|
|
// Going deeper into the old timeline would show content the operator has already replaced.
|
|
expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" });
|
|
});
|
|
|
|
it("holds when no slot has a cached answer, rather than inventing one", () => {
|
|
const persisted = snapshot({ selections: [] });
|
|
|
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
|
|
|
expect(evaluation.resolution).toMatchObject({ kind: "hold" });
|
|
});
|
|
|
|
it("goes black only when the cached timeline genuinely has nothing eligible", () => {
|
|
const persisted = snapshot({
|
|
timeline: {
|
|
eligibleFromMs: null,
|
|
eligibleUntilMs: null,
|
|
id: "timeline-1",
|
|
revision: 2,
|
|
slots: [slot("slot-a", 0, { enabled: false })],
|
|
},
|
|
});
|
|
|
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
|
|
|
expect(evaluation.resolution).toEqual({ kind: "empty" });
|
|
});
|
|
|
|
it("persists no delivery URL, because a stored one is a credential that stops working", () => {
|
|
const persisted = snapshotFrom({
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
localNowMs: 0,
|
|
playback: {
|
|
blackout: false,
|
|
currentSelectionId: "sel-a",
|
|
currentSlotId: "slot-a",
|
|
deadlineAtMs: 5000,
|
|
pendingTimelineId: null,
|
|
remainingMs: 5000,
|
|
revision: 1,
|
|
startedAtMs: 0,
|
|
transport: "playing",
|
|
},
|
|
selections: [
|
|
{
|
|
...selection({ id: "sel-a", slotId: "slot-a" }),
|
|
media: {
|
|
display: { url: "https://cdn.invalid/a?sig=secret" },
|
|
thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" },
|
|
},
|
|
},
|
|
],
|
|
serverNowMs: 0,
|
|
staticHold: null,
|
|
timeline: null,
|
|
});
|
|
|
|
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
|
|
expect(JSON.stringify(persisted)).not.toContain("https://");
|
|
});
|
|
});
|
|
|
|
describe("a display that loses its network", () => {
|
|
let root: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
root = document.createElement("div");
|
|
document.body.append(root);
|
|
});
|
|
|
|
async function settle(): Promise<void> {
|
|
for (let index = 0; index < 12; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
function nativeSelection(id: string, slotId: string): SceneSelection {
|
|
return {
|
|
...selection({ id, slotId }),
|
|
media: {
|
|
display: { url: `https://cdn.invalid/${id}?sig=a` },
|
|
thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
|
|
},
|
|
} as SceneSelection;
|
|
}
|
|
|
|
function state(currentId: string): DisplayChannelStateView {
|
|
const selections = [
|
|
nativeSelection("sel-c", "slot-c"),
|
|
nativeSelection("sel-b", "slot-b"),
|
|
nativeSelection("sel-a", "slot-a"),
|
|
];
|
|
const current = selections.find((candidate) => candidate.id === currentId);
|
|
return {
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
current: current ?? null,
|
|
playback: {
|
|
activeTimelineId: "timeline-1",
|
|
blackout: false,
|
|
currentSelectionId: currentId,
|
|
currentSlotId: current?.slotId ?? null,
|
|
deadlineAtMs: 5000,
|
|
pendingTimelineId: null,
|
|
remainingMs: 5000,
|
|
revision: 3,
|
|
selectionRevision: 1,
|
|
startedAtMs: 0,
|
|
transport: "playing",
|
|
},
|
|
selections,
|
|
serverNowMs: 0,
|
|
staticHold: null,
|
|
timeline: {
|
|
eligibleFromMs: null,
|
|
eligibleUntilMs: null,
|
|
id: "timeline-1",
|
|
revision: 2,
|
|
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
|
|
},
|
|
};
|
|
}
|
|
|
|
it("keeps rendering from cached bytes and cached answers when the server goes away", async () => {
|
|
const store = createMemoryStore();
|
|
const media = createMemoryMediaCache();
|
|
let reachable = true;
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{
|
|
method: "POST",
|
|
path: "viewers/session",
|
|
respond: () =>
|
|
jsonResponse({
|
|
channelId: "chan-1",
|
|
channelKey: "default",
|
|
groupId: "group-1",
|
|
heartbeatIntervalMs: 12_000,
|
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
|
}),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "channels/chan-1/display-state",
|
|
respond: () => {
|
|
if (!reachable) {
|
|
throw new Error("network is gone");
|
|
}
|
|
return jsonResponse(state("sel-a"));
|
|
},
|
|
},
|
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
|
],
|
|
});
|
|
|
|
let clock = 1_000_000;
|
|
// The armed timer is captured rather than run, so the test decides when the safety-net refresh
|
|
// and the offline evaluation happen — no real time passes.
|
|
let armed: (() => void) | undefined;
|
|
const controller = createDisplayController({
|
|
context: host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media,
|
|
newViewerKey: () => "viewer-fixed-key-0001",
|
|
now: () => clock,
|
|
root,
|
|
setTimer: (handler) => {
|
|
armed = handler;
|
|
return handler;
|
|
},
|
|
store,
|
|
});
|
|
|
|
await controller.start();
|
|
await settle();
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
|
|
expect(controller.diagnostics().offline).toBe(false);
|
|
expect(controller.diagnostics().offlineReady).toBe(true);
|
|
|
|
// The uplink dies, and the current slot's deadline passes.
|
|
reachable = false;
|
|
clock += 6000;
|
|
armed?.();
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
// Local continuation took over: the next slot in the pattern, from bytes and answers this screen
|
|
// already had. Nothing was invented, and the slate never appeared.
|
|
expect(diagnostics.offline).toBe(true);
|
|
expect(diagnostics.renderedSelectionId).toBe("sel-b");
|
|
expect(root.dataset.slate).toBe("false");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("puts the last known good frame up before the first request returns", async () => {
|
|
const store = createMemoryStore();
|
|
const media = createMemoryMediaCache();
|
|
await media.store(
|
|
signageMediaKey({
|
|
itemId: "item-sel-a",
|
|
itemRevision: "rev-1",
|
|
providerId: "signage.native-images",
|
|
rendition: "display",
|
|
}),
|
|
"https://cdn.invalid/sel-a?sig=a",
|
|
);
|
|
await store.write("last-known-good", snapshot());
|
|
|
|
let requested = 0;
|
|
const host = createFakeHost({
|
|
permissions: ["signage.display.view"],
|
|
routes: [
|
|
{
|
|
method: "POST",
|
|
path: "viewers/session",
|
|
respond: () => {
|
|
requested += 1;
|
|
throw new Error("network is gone");
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const controller = createDisplayController({
|
|
context: host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media,
|
|
now: () => 1_000_000,
|
|
root,
|
|
setTimer: () => undefined,
|
|
store,
|
|
});
|
|
|
|
await controller.start();
|
|
await settle();
|
|
|
|
// A frame is up, from persisted state, with no server involved — and the slate never appeared.
|
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
|
|
expect(root.dataset.slate).toBe("false");
|
|
expect(requested).toBe(1);
|
|
await controller.stop();
|
|
});
|
|
|
|
it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => {
|
|
const store = createMemoryStore();
|
|
const media = createMemoryMediaCache();
|
|
await store.write("last-known-good", snapshot());
|
|
for (const id of ["sel-a", "sel-b", "sel-c"]) {
|
|
await media.store(
|
|
signageMediaKey({
|
|
itemId: `item-${id}`,
|
|
itemRevision: "rev-1",
|
|
providerId: "signage.native-images",
|
|
rendition: "display",
|
|
}),
|
|
`https://cdn.invalid/${id}?sig=a`,
|
|
);
|
|
}
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = createDisplayController({
|
|
context: host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media,
|
|
now: () => 1_000_000,
|
|
root,
|
|
setTimer: () => undefined,
|
|
store,
|
|
telemetry: false,
|
|
});
|
|
|
|
await controller.start();
|
|
await settle();
|
|
const offlineFrame = controller.diagnostics().renderedSelectionId;
|
|
expect(offlineFrame).toBe("sel-a");
|
|
expect(controller.diagnostics().offline).toBe(true);
|
|
|
|
// The server comes back and says something different. It wins immediately.
|
|
await controller.apply(state("sel-c"));
|
|
await settle();
|
|
|
|
const diagnostics = controller.diagnostics();
|
|
expect(diagnostics.renderedSelectionId).toBe("sel-c");
|
|
expect(diagnostics.offline).toBe(false);
|
|
// Straight to what the server says. Nothing in between was replayed, and the slate never showed.
|
|
expect(root.dataset.slate).toBe("false");
|
|
await controller.stop();
|
|
});
|
|
|
|
it("prunes superseded bytes only after the new state is usable", async () => {
|
|
const store = createMemoryStore();
|
|
const media = createMemoryMediaCache();
|
|
const pruned: readonly string[][] = [];
|
|
const order: string[] = [];
|
|
const wrapped = {
|
|
...media,
|
|
prune: (keep: readonly string[]) => {
|
|
order.push("prune");
|
|
(pruned as string[][]).push([...keep]);
|
|
return media.prune(keep);
|
|
},
|
|
store: (key: string, url: string) => {
|
|
order.push(`store:${key}`);
|
|
return media.store(key, url);
|
|
},
|
|
};
|
|
const host = createFakeHost({ permissions: [], routes: [] });
|
|
const controller = createDisplayController({
|
|
context: host.context,
|
|
decodeImage: () => Promise.resolve(),
|
|
media: wrapped,
|
|
now: () => 1_000_000,
|
|
root,
|
|
setTimer: () => undefined,
|
|
store,
|
|
telemetry: false,
|
|
});
|
|
|
|
await controller.start();
|
|
await controller.apply(state("sel-a"));
|
|
await settle();
|
|
|
|
// Every store happens before the prune: a screen never has less than it had.
|
|
expect(order.at(-1)).toBe("prune");
|
|
expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3);
|
|
await controller.stop();
|
|
});
|
|
});
|