Files
Signage/tests/browser/offline.test.ts
T
2026-08-28 16:41:54 -05:00

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();
});
});