CI / lint (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 25s
CI / build (pull_request) Successful in 32s
CI / test (pull_request) Successful in 33s
CI / package (pull_request) Successful in 24s
CI / integration (pull_request) Successful in 8m43s
A display opened over plain http:// on a LAN address is not a secure context, so the browser gives it no Cache Storage. The media cache then fell back to the in-memory test double, which remembered only each slide's signed delivery URL and still reported it as cached. Online that looked identical. Offline, the next slide's URL could not be loaded, the slide was marked unusable, and local continuation kept landing on the image already on screen. The wall froze until the network returned. Without Cache Storage, the browser media cache now fetches and holds the bytes in page memory as object URLs (one per key, revoked on prune, replace and stop), so a running display keeps cycling through what it already has. Nothing persists across a reload; on such an origin the platform cannot cache the display page either. Regression tests: the media cache on an origin without Cache Storage, and the whole controller on an ordered fake clock, cycling for six slide intervals with the network gone and then handing back to the server. Co-Authored-By: Claude Opus 5.5 <[email protected]>
834 lines
27 KiB
TypeScript
834 lines
27 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, 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 {
|
|
createBrowserMediaCache,
|
|
createMemoryMediaCache,
|
|
createMemoryStore,
|
|
signageMediaKey,
|
|
} from "../../src/browser/storage.js";
|
|
import {
|
|
createDisplayController,
|
|
type DisplayChannelStateView,
|
|
} from "../../src/display/controller.js";
|
|
import type { DisplaySelection, ViewerSessionResponse } from "../../src/shared/display-protocol.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));
|
|
}
|
|
}
|
|
|
|
/** A native selection exactly as the server sends one in channel state. */
|
|
function nativeSelection(id: string, slotId: string): DisplaySelection {
|
|
return {
|
|
...selection({ id, slotId }),
|
|
media: {
|
|
display: {
|
|
expiresAtMs: 900_000,
|
|
height: 1080,
|
|
url: `https://cdn.invalid/${id}?sig=a`,
|
|
width: 1920,
|
|
},
|
|
thumbnail: {
|
|
expiresAtMs: 900_000,
|
|
height: 270,
|
|
url: `https://cdn.invalid/${id}-t?sig=a`,
|
|
width: 480,
|
|
},
|
|
},
|
|
stale: false,
|
|
};
|
|
}
|
|
|
|
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",
|
|
viewerName: "Foyer",
|
|
} satisfies ViewerSessionResponse),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
|
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();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Stage 0's real kiosk: two native images cycling, then the laptop's network switched off for a
|
|
* minute. The offline note appeared, but the wall stayed on one image until the network returned.
|
|
*
|
|
* The page was served over plain `http://` on a LAN address, which a browser does not treat as a
|
|
* secure context, so there was no Cache Storage. The media cache's fallback then remembered only each
|
|
* slide's signed delivery URL. Offline, the next slide's URL could not be fetched, the slide was
|
|
* marked unusable, and local continuation kept landing on the one image already on screen.
|
|
*
|
|
* This runs the whole controller on a clock that only moves when the test says so, across several
|
|
* slide intervals, with every timer it arms honoured in order.
|
|
*/
|
|
describe("a running display that loses its network for several slides", () => {
|
|
const SLOT_MS = 5000;
|
|
const realCreate = URL.createObjectURL;
|
|
const realRevoke = URL.revokeObjectURL;
|
|
let root: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
root = document.createElement("div");
|
|
document.body.append(root);
|
|
let next = 0;
|
|
URL.createObjectURL = () => {
|
|
next += 1;
|
|
return `blob:display/${next}`;
|
|
};
|
|
URL.revokeObjectURL = () => undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
URL.createObjectURL = realCreate;
|
|
URL.revokeObjectURL = realRevoke;
|
|
});
|
|
|
|
async function settle(): Promise<void> {
|
|
for (let index = 0; index < 12; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
/** A clock and timer queue the controller and its stage both run on. */
|
|
function createClock(startMs: number) {
|
|
let nowMs = startMs;
|
|
let nextHandle = 0;
|
|
const timers = new Map<number, { readonly atMs: number; readonly handler: () => void }>();
|
|
return {
|
|
clearTimer: (handle: unknown) => {
|
|
timers.delete(handle as number);
|
|
},
|
|
now: () => nowMs,
|
|
setTimer: (handler: () => void, delayMs: number) => {
|
|
nextHandle += 1;
|
|
timers.set(nextHandle, { atMs: nowMs + Math.max(0, delayMs), handler });
|
|
return nextHandle;
|
|
},
|
|
/** Moves time forward, running every timer that falls due on the way, in order. */
|
|
async advance(ms: number): Promise<void> {
|
|
const targetMs = nowMs + ms;
|
|
for (;;) {
|
|
let due: [number, { readonly atMs: number; readonly handler: () => void }] | undefined;
|
|
for (const entry of timers) {
|
|
if (entry[1].atMs <= targetMs && (!due || entry[1].atMs < due[1].atMs)) {
|
|
due = entry;
|
|
}
|
|
}
|
|
if (!due) {
|
|
break;
|
|
}
|
|
timers.delete(due[0]);
|
|
nowMs = due[1].atMs;
|
|
due[1].handler();
|
|
await settle();
|
|
}
|
|
nowMs = targetMs;
|
|
await settle();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Which of the two images a selection is. */
|
|
function slideOf(selectionId: string | undefined): string | undefined {
|
|
return selectionId?.split("-")[1];
|
|
}
|
|
|
|
/** The server's answer at `atMs`: two images alternating, each play its own selection, and a
|
|
* freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */
|
|
function serverState(atMs: number): DisplayChannelStateView {
|
|
const cycle = Math.floor(atMs / SLOT_MS);
|
|
const slide = (c: number) => (c % 2 === 0 ? "a" : "b");
|
|
const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2]
|
|
.filter((c) => c >= 0)
|
|
.map((c) => ({
|
|
...selection({
|
|
id: `sel-${slide(c)}-${c}`,
|
|
itemId: `item-${slide(c)}`,
|
|
slotId: `slot-${slide(c)}`,
|
|
}),
|
|
media: {
|
|
display: {
|
|
expiresAtMs: atMs + 900_000,
|
|
height: 1080,
|
|
url: `http://files.example.test/${slide(c)}?sig=${atMs}`,
|
|
width: 1920,
|
|
},
|
|
thumbnail: {
|
|
expiresAtMs: atMs + 900_000,
|
|
height: 270,
|
|
url: `http://files.example.test/${slide(c)}-t?sig=${atMs}`,
|
|
width: 480,
|
|
},
|
|
},
|
|
stale: false,
|
|
}));
|
|
const current = history[0] as DisplaySelection;
|
|
const startedAtMs = cycle * SLOT_MS;
|
|
return {
|
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
|
crossfadeMs: 0,
|
|
current,
|
|
playback: {
|
|
activeTimelineId: "timeline-1",
|
|
blackout: false,
|
|
currentSelectionId: current.id,
|
|
currentSlotId: current.slotId,
|
|
deadlineAtMs: startedAtMs + SLOT_MS,
|
|
pendingTimelineId: null,
|
|
remainingMs: startedAtMs + SLOT_MS - atMs,
|
|
revision: cycle,
|
|
selectionRevision: cycle,
|
|
startedAtMs,
|
|
transport: "playing",
|
|
},
|
|
selections: history,
|
|
serverNowMs: atMs,
|
|
staticHold: null,
|
|
timeline: {
|
|
eligibleFromMs: null,
|
|
eligibleUntilMs: null,
|
|
id: "timeline-1",
|
|
revision: 1,
|
|
slots: [
|
|
slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }),
|
|
slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }),
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
it("keeps advancing through its cached images on an origin with no Cache Storage", async () => {
|
|
const network = { reachable: true };
|
|
// Mid-slide, so every sample below lands halfway through one.
|
|
const clock = createClock(12_500);
|
|
const unreachable = () => new TypeError("Failed to fetch");
|
|
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",
|
|
viewerName: "Foyer",
|
|
} satisfies ViewerSessionResponse),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: /^viewers\/[^/]+\/display-state$/u,
|
|
respond: () => {
|
|
if (!network.reachable) {
|
|
throw unreachable();
|
|
}
|
|
return jsonResponse(serverState(clock.now()));
|
|
},
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: /^viewers\/.*\/heartbeat$/u,
|
|
respond: () => {
|
|
if (!network.reachable) {
|
|
throw unreachable();
|
|
}
|
|
return jsonResponse({});
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else.
|
|
const media = createBrowserMediaCache({
|
|
fetch: async () => {
|
|
if (!network.reachable) {
|
|
throw unreachable();
|
|
}
|
|
return new Response(new Uint8Array([1, 2, 3]), {
|
|
headers: { "content-type": "image/webp" },
|
|
});
|
|
},
|
|
location: { origin: "http://192.168.1.20:3000" },
|
|
} as unknown as typeof globalThis);
|
|
|
|
const controller = createDisplayController({
|
|
clearTimer: clock.clearTimer,
|
|
context: host.context,
|
|
// What a browser does: bytes this page holds always decode, and a remote URL needs a network.
|
|
decodeImage: (image) =>
|
|
(image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable
|
|
? Promise.resolve()
|
|
: Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
|
media,
|
|
newViewerKey: () => "viewer-fixed-key-0001",
|
|
now: clock.now,
|
|
root,
|
|
setTimer: clock.setTimer,
|
|
store: createMemoryStore(),
|
|
});
|
|
|
|
await controller.start();
|
|
await settle();
|
|
// Online for a full cycle, so both images have been shown and fetched.
|
|
const online: (string | undefined)[] = [slideOf(controller.diagnostics().renderedSelectionId)];
|
|
for (let interval = 0; interval < 2; interval += 1) {
|
|
await clock.advance(SLOT_MS);
|
|
online.push(slideOf(controller.diagnostics().renderedSelectionId));
|
|
}
|
|
expect(online).toEqual(["a", "b", "a"]);
|
|
expect(controller.diagnostics().offline).toBe(false);
|
|
|
|
// The network goes away for six slides.
|
|
network.reachable = false;
|
|
const offline: (string | undefined)[] = [];
|
|
for (let interval = 0; interval < 6; interval += 1) {
|
|
await clock.advance(SLOT_MS);
|
|
offline.push(slideOf(controller.diagnostics().renderedSelectionId));
|
|
expect(controller.diagnostics().offline).toBe(true);
|
|
}
|
|
|
|
// Still cycling, on the server's own phase, from bytes the page already held.
|
|
expect(offline).toEqual(["b", "a", "b", "a", "b", "a"]);
|
|
expect(controller.diagnostics().unusableSelectionIds).toEqual([]);
|
|
expect(root.dataset.slate).toBe("false");
|
|
|
|
// The network returns. The server is authoritative again with no reload.
|
|
network.reachable = true;
|
|
await clock.advance(70_000);
|
|
const diagnostics = controller.diagnostics();
|
|
expect(diagnostics.offline).toBe(false);
|
|
expect(diagnostics.renderedSelectionId).toMatch(/^sel-[ab]-\d+$/u);
|
|
expect(Number(diagnostics.renderedSelectionId?.split("-")[2])).toBeGreaterThan(10);
|
|
await controller.stop();
|
|
});
|
|
});
|