Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41dbefe552 | ||
|
|
9aa82068f1 | ||
|
|
51d9085cdb | ||
|
|
ab5b02960a | ||
|
|
5f52334664 | ||
|
|
b479ae9296 | ||
|
|
f8b131f4ed | ||
|
|
649114ba1d | ||
|
|
073fbc5855 |
@@ -1,3 +1,3 @@
|
||||
# Ekklesia's module-development packages (@ekklesia/*) come from Ekklesia's public package registry
|
||||
# (platform ADRs 0053 and 0054). Reading needs no account and no token; never put a credential here.
|
||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||
|
||||
@@ -64,6 +64,13 @@ decision gets a new ADR that supersedes the old one; never rewrite a historical
|
||||
with the compatibility boundary explicit. Do not copy platform logic into Signage to avoid it.
|
||||
- Every defect fix gets a regression test. Signage never reads the environment, opens its own socket,
|
||||
builds an Ekklesia URL, or applies a migration; all of that reaches it through the host.
|
||||
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
|
||||
task at hand gets an issue in the repository it belongs to (here or `Zayin/Ekklesia`), as soon as
|
||||
you find it. Search for an existing issue first; something fixed in the same PR goes in that PR's
|
||||
description instead. Be descriptive: what you saw, the evidence (run and job links, commit, file
|
||||
and line), how to reproduce it, the impact, and options, and never a secret. Label it
|
||||
`found-by-claude` (an agent that cannot apply labels ends the body with a line saying so), leave
|
||||
it unassigned, and list what you filed when you report back.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
|
||||
EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git
|
||||
EKKLESIA_PLATFORM_REF=refs/pull/92/head
|
||||
EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a
|
||||
EKKLESIA_PLATFORM_COMMIT=5e9433d96b413117374a6132d89400867414c2c8
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: Ekklesia [ADR 0006](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||
(versioned capabilities), [ADR 0041](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||
- Related: Ekklesia [ADR 0006](https://git.zayin.net/Zayin/Ekklesia)
|
||||
(versioned capabilities), [ADR 0041](https://git.zayin.net/Zayin/Ekklesia)
|
||||
(browser contributions)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0005](0005-authoritative-playback.md), Ekklesia
|
||||
[ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code)
|
||||
[ADR 0043](https://git.zayin.net/Zayin/Ekklesia) (display offline code)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia
|
||||
[ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage),
|
||||
[ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies)
|
||||
[ADR 0030](https://git.zayin.net/Zayin/Ekklesia) (host-provided storage),
|
||||
[ADR 0042](https://git.zayin.net/Zayin/Ekklesia) (streaming bodies)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -136,13 +136,13 @@ packages:
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-h1kwuurRp2sOdrVxVtU7TbDx3NoOKEj1G7jhLRawFsWrlqkuhtcBgRXXfHgC5cbEwh5VRfXT3viLwwHnFIRjdw==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fcontracts/-/0.2.3/contracts-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-h1kwuurRp2sOdrVxVtU7TbDx3NoOKEj1G7jhLRawFsWrlqkuhtcBgRXXfHgC5cbEwh5VRfXT3viLwwHnFIRjdw==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fcontracts/-/0.2.3/contracts-0.2.3.tgz}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-ZN4x/qehXxnWccWrICyMMDgHXvXoKyicFc/qm8wmf3513PTBzM9lDtfrgnfBLEoc+mjeEfTTnfOyigX97U56XA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fdesign-tokens/-/0.2.3/design-tokens-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-ZN4x/qehXxnWccWrICyMMDgHXvXoKyicFc/qm8wmf3513PTBzM9lDtfrgnfBLEoc+mjeEfTTnfOyigX97U56XA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fdesign-tokens/-/0.2.3/design-tokens-0.2.3.tgz}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-xGwFPPD/wjbfWqgB45TpGVdLNC7eUb6j5l2wgRdpGCpTScnVsS30EJmgwsplPi6uHErUiNIZBwuN6YV5jVcZDg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-database/-/0.2.3/module-database-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-xGwFPPD/wjbfWqgB45TpGVdLNC7eUb6j5l2wgRdpGCpTScnVsS30EJmgwsplPi6uHErUiNIZBwuN6YV5jVcZDg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-database/-/0.2.3/module-database-0.2.3.tgz}
|
||||
peerDependencies:
|
||||
drizzle-orm: '>=0.45.2 <0.46.0'
|
||||
peerDependenciesMeta:
|
||||
@@ -150,24 +150,24 @@ packages:
|
||||
optional: true
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-U755u1sctmLm1bS9uaWQjcHPZw8WCYSTY5Ddi4QTbLVB3QzqYaWCaOyeeKJyEtoI+iZQNFkTAjAQPMSCVbCj8g==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-pack/-/0.2.3/module-pack-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-U755u1sctmLm1bS9uaWQjcHPZw8WCYSTY5Ddi4QTbLVB3QzqYaWCaOyeeKJyEtoI+iZQNFkTAjAQPMSCVbCj8g==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-pack/-/0.2.3/module-pack-0.2.3.tgz}
|
||||
hasBin: true
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-H6g2F2xfA/akmzWBfSQTtUtgIrGkHxzPpvbJk4EqUJ6eXllHtn82hAw9r0S7vjLWE9IMD9m9KLAJ3Xil3mZsig==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-sdk/-/0.2.3/module-sdk-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-H6g2F2xfA/akmzWBfSQTtUtgIrGkHxzPpvbJk4EqUJ6eXllHtn82hAw9r0S7vjLWE9IMD9m9KLAJ3Xil3mZsig==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-sdk/-/0.2.3/module-sdk-0.2.3.tgz}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-rSymIjPeoDzxpXpTC7D74/g8MV5PgUP56dHGXxe/jrvlvfdFeNa7pSMUYa4lKc3DvlcJucvq4BjwGXRuDUCdEA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-ui-build/-/0.2.3/module-ui-build-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-rSymIjPeoDzxpXpTC7D74/g8MV5PgUP56dHGXxe/jrvlvfdFeNa7pSMUYa4lKc3DvlcJucvq4BjwGXRuDUCdEA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-ui-build/-/0.2.3/module-ui-build-0.2.3.tgz}
|
||||
hasBin: true
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-zSvI3gfnqBiBt7h0Xbs6xmsLYP9E8M0xpLGVno+OaC1cyrKVUvlwq78as2zoiTGFHl2aoI5Mz9V4/KJOVxyByg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-ui-sdk/-/0.2.3/module-ui-sdk-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-zSvI3gfnqBiBt7h0Xbs6xmsLYP9E8M0xpLGVno+OaC1cyrKVUvlwq78as2zoiTGFHl2aoI5Mz9V4/KJOVxyByg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-ui-sdk/-/0.2.3/module-ui-sdk-0.2.3.tgz}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-dm8d8hJtdwwaMpsIwXcPXdCu616RQu3Y3/5OacPS1L2eb7Bhnxfl5SQJMuee8CtUSs45kBr0KXN8VWHl+AxSXg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Ftypescript-config/-/0.2.3/typescript-config-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-dm8d8hJtdwwaMpsIwXcPXdCu616RQu3Y3/5OacPS1L2eb7Bhnxfl5SQJMuee8CtUSs45kBr0KXN8VWHl+AxSXg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Ftypescript-config/-/0.2.3/typescript-config-0.2.3.tgz}
|
||||
|
||||
'@ekklesia/[email protected]':
|
||||
resolution: {integrity: sha512-9xNOnp47VvARP91+XFG8mo/F5ln1Zi+w2RVY0mBeyf6qO5P39nyOAzwxunsD44Mb3E1o+fCTTgZoSJbLMaRtMA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fui/-/0.2.3/ui-0.2.3.tgz}
|
||||
resolution: {integrity: sha512-9xNOnp47VvARP91+XFG8mo/F5ln1Zi+w2RVY0mBeyf6qO5P39nyOAzwxunsD44Mb3E1o+fCTTgZoSJbLMaRtMA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fui/-/0.2.3/ui-0.2.3.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
|
||||
+115
-7
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
advanceCursor,
|
||||
eligibleSlots,
|
||||
firstEligibilityBoundary,
|
||||
nextTimingBoundary,
|
||||
TIMING_TRANSITION_LIMIT,
|
||||
type TimingCursor,
|
||||
type TimingSlot,
|
||||
type TimingTimeline,
|
||||
} from "../shared/playback-timing.js";
|
||||
@@ -111,17 +115,110 @@ function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet
|
||||
return selection.availableUntilMs === null || atMs < selection.availableUntilMs;
|
||||
}
|
||||
|
||||
/** The most recent cached answer for one slot. History is newest-first, so the first match is the
|
||||
* one that most recently played there. */
|
||||
function cachedForSlot(
|
||||
/**
|
||||
* The answers one slot has already had, as the rotation they came in: one per item, oldest-played
|
||||
* first, so the item on screen most recently is last.
|
||||
*
|
||||
* A pinned slot has one item and so a rotation of one. A provider-source slot is answered with the
|
||||
* provider's *next* item on every play, so its history is the provider's own order — and replaying
|
||||
* only the newest answer would replay the image already on screen at every boundary. Each item's
|
||||
* newest answer is the one that counts: an item whose newest answer was withdrawn or failed here is
|
||||
* left out, never replaced by an older answer for it.
|
||||
*/
|
||||
function rotationForSlot(
|
||||
snapshot: OfflineSnapshot,
|
||||
slotId: string,
|
||||
atMs: number,
|
||||
unusable: ReadonlySet<string>,
|
||||
): readonly SceneSelection[] {
|
||||
const seen = new Set<string>();
|
||||
const newestFirst: SceneSelection[] = [];
|
||||
// History is newest-first, so the first answer seen for an item is its most recent one.
|
||||
for (const selection of snapshot.selections) {
|
||||
if (selection.slotId !== slotId) {
|
||||
continue;
|
||||
}
|
||||
const item = `${selection.providerId}\u0000${selection.itemId}`;
|
||||
if (seen.has(item)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item);
|
||||
if (isUsable(selection, atMs, unusable)) {
|
||||
newestFirst.push(selection);
|
||||
}
|
||||
}
|
||||
return newestFirst.reverse();
|
||||
}
|
||||
|
||||
/** The cached answer for the `play`-th play of a slot since the anchor (1 is the next one). */
|
||||
function cachedForSlot(
|
||||
snapshot: OfflineSnapshot,
|
||||
slotId: string,
|
||||
play: number,
|
||||
atMs: number,
|
||||
unusable: ReadonlySet<string>,
|
||||
): SceneSelection | undefined {
|
||||
return snapshot.selections.find(
|
||||
(selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable),
|
||||
);
|
||||
const rotation = rotationForSlot(snapshot, slotId, atMs, unusable);
|
||||
if (rotation.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return rotation[(Math.max(1, play) - 1) % rotation.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times `slotId` has started since the anchor, up to and including a play starting at
|
||||
* `throughMs`.
|
||||
*
|
||||
* Arithmetic, like the cursor itself: wherever the eligible set cannot change, whole passes of the
|
||||
* pattern are counted by division — every eligible slot plays exactly once per pass — so a week
|
||||
* offline is one evaluation. It only chooses *which* cached answer a play shows; where a play
|
||||
* starts is still decided by `advanceCursor` alone.
|
||||
*/
|
||||
function playsSinceAnchor(
|
||||
timeline: TimingTimeline,
|
||||
anchor: TimingCursor,
|
||||
slotId: string,
|
||||
throughMs: number,
|
||||
): number {
|
||||
let cursor = anchor;
|
||||
let plays = 0;
|
||||
for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) {
|
||||
const fromMs = cursor.deadlineAtMs;
|
||||
if (fromMs === null || fromMs > throughMs) {
|
||||
break;
|
||||
}
|
||||
const stable = eligibleSlots(timeline, fromMs);
|
||||
const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0);
|
||||
const changeAt = firstEligibilityBoundary(timeline, fromMs, throughMs);
|
||||
const stableThrough = changeAt === null ? throughMs : changeAt - 1;
|
||||
if (passDuration > 0 && stable.some((slot) => slot.id === cursor.slotId)) {
|
||||
const passes = Math.floor((stableThrough - fromMs) / passDuration);
|
||||
if (passes > 0) {
|
||||
if (stable.some((slot) => slot.id === slotId)) {
|
||||
plays += passes;
|
||||
}
|
||||
cursor = {
|
||||
deadlineAtMs: fromMs + passes * passDuration,
|
||||
slotId: cursor.slotId,
|
||||
startedAtMs: (cursor.startedAtMs ?? fromMs) + passes * passDuration,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const next = advanceCursor(timeline, cursor, fromMs);
|
||||
if (next.kind !== "advance") {
|
||||
break;
|
||||
}
|
||||
if (next.slotId === slotId) {
|
||||
plays += 1;
|
||||
}
|
||||
cursor = {
|
||||
deadlineAtMs: next.deadlineAtMs,
|
||||
slotId: next.slotId,
|
||||
startedAtMs: next.startedAtMs,
|
||||
};
|
||||
}
|
||||
return plays;
|
||||
}
|
||||
|
||||
export interface OfflineEvaluation {
|
||||
@@ -179,6 +276,11 @@ export function resolveOffline(input: {
|
||||
}
|
||||
|
||||
const timing = timingTimelineOf(timeline);
|
||||
const anchor: TimingCursor = {
|
||||
deadlineAtMs: snapshot.playback.deadlineAtMs,
|
||||
slotId: snapshot.playback.currentSlotId,
|
||||
startedAtMs: snapshot.playback.startedAtMs,
|
||||
};
|
||||
let cursor = {
|
||||
deadlineAtMs: snapshot.playback.deadlineAtMs,
|
||||
slotId: snapshot.playback.currentSlotId,
|
||||
@@ -224,7 +326,13 @@ export function resolveOffline(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable);
|
||||
const cached = cachedForSlot(
|
||||
snapshot,
|
||||
advance.slotId,
|
||||
playsSinceAnchor(timing, anchor, advance.slotId, advance.startedAtMs),
|
||||
atMs,
|
||||
unusable,
|
||||
);
|
||||
if (cached) {
|
||||
const nextCursor = {
|
||||
deadlineAtMs: advance.deadlineAtMs,
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface SceneStage {
|
||||
setSlate(on: boolean): void;
|
||||
/** What is on screen now, or `undefined` if nothing ever was. */
|
||||
currentKey(): string | undefined;
|
||||
/** Takes whatever is on screen down, and abandons any transition still preparing. For content
|
||||
* that must no longer be shown at all — never for an ordinary change of slide. */
|
||||
clear(): Promise<void>;
|
||||
setCrossfade(milliseconds: number): void;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
@@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage {
|
||||
}
|
||||
|
||||
return {
|
||||
async clear(): Promise<void> {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
// A transition that is still preparing belongs to the content being taken down.
|
||||
generation += 1;
|
||||
for (const layer of layers) {
|
||||
layer.element.style.transitionDuration = "0ms";
|
||||
layer.element.style.opacity = "0";
|
||||
layer.element.dataset.visible = "false";
|
||||
}
|
||||
await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]);
|
||||
},
|
||||
|
||||
currentKey: () => layers[currentIndex]?.key,
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
|
||||
+63
-3
@@ -159,14 +159,74 @@ export function createBrowserStore(scope: typeof globalThis = globalThis): Signa
|
||||
};
|
||||
}
|
||||
|
||||
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure
|
||||
* origin in development, a policy that refuses — a display works exactly as it did before: online. */
|
||||
/**
|
||||
* Image bytes held in this page's memory, for a browser that offers no Cache Storage — a display
|
||||
* opened over plain `http://` on a LAN address is not a secure context, and gets none.
|
||||
*
|
||||
* The bytes, not the delivery URL: a URL needs the network, so a cache that remembered only URLs
|
||||
* looked identical online and could paint nothing new offline. Holding the bytes keeps a running
|
||||
* display cycling through what it already has. Nothing survives a reload, and nothing needs to: on
|
||||
* such an origin the platform cannot cache the display page itself either.
|
||||
*/
|
||||
function createPageMediaCache(fetchFn: typeof fetch): SignageMediaCache {
|
||||
/** One object URL per key, revoked when the key is pruned or replaced, and all of them on stop. */
|
||||
const objectUrls = new Map<string, string>();
|
||||
|
||||
function forget(key: string): void {
|
||||
const url = objectUrls.get(key);
|
||||
if (url !== undefined) {
|
||||
objectUrls.delete(key);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: () => Promise.resolve(objectUrls.size),
|
||||
find: (key: string) => Promise.resolve(objectUrls.get(key)),
|
||||
prune(keep: readonly string[]): Promise<void> {
|
||||
const retained = new Set(keep);
|
||||
for (const key of [...objectUrls.keys()]) {
|
||||
if (!retained.has(key)) {
|
||||
forget(key);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
release(): void {
|
||||
for (const key of [...objectUrls.keys()]) {
|
||||
forget(key);
|
||||
}
|
||||
},
|
||||
async store(key: string, url: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetchFn(url);
|
||||
// The same rule as Cache Storage: only a complete, successful response is kept.
|
||||
if (!response.ok || response.status !== 200) {
|
||||
return undefined;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
forget(key);
|
||||
const local = URL.createObjectURL(blob);
|
||||
objectUrls.set(key, local);
|
||||
return local;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage, the bytes are
|
||||
* held in the page instead (`createPageMediaCache`), so a running display still survives an outage. */
|
||||
export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
|
||||
const maybeCaches = (scope as { caches?: CacheStorage }).caches;
|
||||
const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
|
||||
if (!maybeCaches || !fetchFn) {
|
||||
if (!fetchFn) {
|
||||
return createMemoryMediaCache();
|
||||
}
|
||||
if (!maybeCaches) {
|
||||
return createPageMediaCache(fetchFn);
|
||||
}
|
||||
const caches: CacheStorage = maybeCaches;
|
||||
|
||||
async function open(): Promise<Cache | undefined> {
|
||||
|
||||
@@ -118,6 +118,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
let stopped = false;
|
||||
let online = false;
|
||||
let renderedSelectionId: string | undefined;
|
||||
/** The assignment the frame on screen came from. A frame is only ever kept for its own assignment. */
|
||||
let frameAssignment: string | undefined;
|
||||
let cachedAssetCount = 0;
|
||||
let lastError: { code: string; detail: string | null } | undefined;
|
||||
let appliedRevision = -1;
|
||||
@@ -169,6 +171,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
};
|
||||
}
|
||||
|
||||
/** Identifies an assignment: the channel, within its group. */
|
||||
function assignmentOf(channel: { readonly id: string; readonly groupId: string }): string {
|
||||
return `${channel.groupId}/${channel.id}`;
|
||||
}
|
||||
|
||||
function recordError(code: string, detail: string | null = null): void {
|
||||
lastError = { code, detail };
|
||||
report();
|
||||
@@ -261,6 +268,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
return;
|
||||
}
|
||||
renderedSelectionId = selection.id;
|
||||
frameAssignment = assignmentOf(state.channel);
|
||||
if (lastError !== undefined) {
|
||||
lastError = undefined;
|
||||
}
|
||||
@@ -287,11 +295,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
|
||||
const target = state.staticHold ?? state.current;
|
||||
if (target === null) {
|
||||
// Nothing renderable from the server. If something good is already on screen it stays; only a
|
||||
// display with nothing at all shows the slate.
|
||||
const assignment = assignmentOf(state.channel);
|
||||
// Nothing renderable from the server. A good frame of *this* assignment stays through the gap;
|
||||
// a frame from an assignment this display no longer has is stale content, and comes down.
|
||||
if (frameAssignment !== undefined && frameAssignment !== assignment) {
|
||||
stage.setSlate(true);
|
||||
await stage.clear();
|
||||
renderedSelectionId = undefined;
|
||||
frameAssignment = undefined;
|
||||
}
|
||||
if (stage.currentKey() === undefined) {
|
||||
stage.setSlate(true);
|
||||
}
|
||||
// What an outage or a restart resumes from must be this assignment too, never the old one.
|
||||
if (snapshot !== undefined && assignmentOf(snapshot.channel) !== assignment) {
|
||||
await persist(state);
|
||||
}
|
||||
report();
|
||||
return;
|
||||
}
|
||||
@@ -428,6 +447,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
}
|
||||
if (outcome.kind !== "superseded") {
|
||||
renderedSelectionId = selection.id;
|
||||
frameAssignment = assignmentOf(snapshot.channel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -546,6 +546,128 @@ describe("display controller", () => {
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Stage 0: moved from a group that was playing to one with nothing to show, the display adopted
|
||||
* the new assignment and kept the old group's last image on screen indefinitely. "Keep the last
|
||||
* good frame" protects a frame of the *same* assignment from a gap in it; a frame from an
|
||||
* assignment this display no longer has is not a good frame, it is stale content.
|
||||
*/
|
||||
function movedToEmptyGroup(store: ReturnType<typeof createMemoryStore>) {
|
||||
const moved = { groupId: "group-2", id: "chan-2", key: "online" };
|
||||
let stateFetches = 0;
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.display.view"],
|
||||
routes: [
|
||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||
{
|
||||
method: "GET",
|
||||
path: /^viewers\/[^/]+\/display-state$/u,
|
||||
respond: () => {
|
||||
stateFetches += 1;
|
||||
// Playing in group-1; moved to group-2, which has nothing, right after its first fetch.
|
||||
return jsonResponse(
|
||||
state(
|
||||
stateFetches === 1
|
||||
? {
|
||||
timeline: {
|
||||
eligibleFromMs: null,
|
||||
eligibleUntilMs: null,
|
||||
id: "timeline-1",
|
||||
revision: 1,
|
||||
slots: [
|
||||
{
|
||||
eligibleFromMs: null,
|
||||
eligibleUntilMs: null,
|
||||
enabled: true,
|
||||
estimatedDurationMs: 5000,
|
||||
id: "slot-1",
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {
|
||||
channel: moved,
|
||||
current: null,
|
||||
playback: {
|
||||
activeTimelineId: null,
|
||||
blackout: false,
|
||||
currentSelectionId: null,
|
||||
currentSlotId: null,
|
||||
deadlineAtMs: null,
|
||||
pendingTimelineId: null,
|
||||
remainingMs: null,
|
||||
revision: 1,
|
||||
selectionRevision: null,
|
||||
startedAtMs: null,
|
||||
transport: "playing",
|
||||
},
|
||||
selections: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: /^viewers\/.*\/heartbeat$/u,
|
||||
respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }),
|
||||
},
|
||||
],
|
||||
});
|
||||
return { fetches: () => stateFetches, host, store };
|
||||
}
|
||||
|
||||
it("stops showing the previous group's image when moved to a group with nothing to show", async () => {
|
||||
const media = createMemoryMediaCache();
|
||||
const scenario = movedToEmptyGroup(createMemoryStore());
|
||||
const controller = build({ host: scenario.host, media, store: scenario.store });
|
||||
await controller.start();
|
||||
await settle();
|
||||
|
||||
expect(scenario.fetches()).toBe(2);
|
||||
const diagnostics = controller.diagnostics();
|
||||
expect(diagnostics.channelId).toBe("chan-2");
|
||||
// The old group's image is gone — not merely covered — and the wall says so plainly.
|
||||
expect(diagnostics.renderedSelectionId).toBeUndefined();
|
||||
expect(diagnostics.renderedKey).toBeUndefined();
|
||||
expect(diagnostics.slate).toBe(true);
|
||||
expect(root.dataset.slate).toBe("true");
|
||||
expect(root.querySelector("img.signage-layer__foreground[src]")).toBeNull();
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("does not bring the previous group's content back from local state after that move", async () => {
|
||||
const media = createMemoryMediaCache();
|
||||
const scenario = movedToEmptyGroup(createMemoryStore());
|
||||
const first = build({ host: scenario.host, media, store: scenario.store });
|
||||
await first.start();
|
||||
await settle();
|
||||
await first.stop();
|
||||
|
||||
// The kiosk restarts while the server is unreachable. It resumes from what it persisted — which
|
||||
// must be its current assignment, not the group it was moved away from.
|
||||
const unreachable = createFakeHost({
|
||||
permissions: ["signage.display.view"],
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "viewers/session",
|
||||
respond: () => {
|
||||
throw new Error("network is gone");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const restarted = build({ host: unreachable, media, store: scenario.store });
|
||||
await restarted.start();
|
||||
await settle();
|
||||
|
||||
expect(restarted.diagnostics().renderedSelectionId).toBeUndefined();
|
||||
expect(root.dataset.slate).toBe("true");
|
||||
await restarted.stop();
|
||||
});
|
||||
|
||||
it("registers again, exactly once, when it was removed from the roster", async () => {
|
||||
let sessions = 0;
|
||||
let subscriptions = 0;
|
||||
|
||||
@@ -117,3 +117,90 @@ describe("Signage's media cache and object URLs", () => {
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A display opened over plain `http://` on a LAN address is not a secure context, and a browser
|
||||
* gives such a page no Cache Storage. Stage 0's real kiosk ran exactly that way.
|
||||
*
|
||||
* The fallback used to remember only the signed delivery URL and report it as cached. Online that
|
||||
* looked identical; offline, the next slide's URL could not be fetched, the slide was skipped as
|
||||
* unusable, and the wall sat on one image until the network came back. The bytes are what a display
|
||||
* needs offline, so the bytes are what this keeps — in the page's memory, for as long as it runs.
|
||||
*/
|
||||
describe("Signage's media cache on an origin without Cache Storage", () => {
|
||||
const realCreate = URL.createObjectURL;
|
||||
const realRevoke = URL.revokeObjectURL;
|
||||
let live: Set<string>;
|
||||
let reachable: boolean;
|
||||
let media: SignageMediaCache;
|
||||
|
||||
beforeEach(() => {
|
||||
live = new Set();
|
||||
reachable = true;
|
||||
let next = 0;
|
||||
URL.createObjectURL = () => {
|
||||
next += 1;
|
||||
const url = `blob:test/${next}`;
|
||||
live.add(url);
|
||||
return url;
|
||||
};
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
live.delete(url);
|
||||
};
|
||||
media = createBrowserMediaCache({
|
||||
// No `caches`: what an insecure origin gets.
|
||||
fetch: async (url: string) => {
|
||||
if (!reachable) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return url.includes("missing")
|
||||
? new Response("not found", { status: 404 })
|
||||
: 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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = realCreate;
|
||||
URL.revokeObjectURL = realRevoke;
|
||||
});
|
||||
|
||||
it("keeps the bytes, so a slide it cached can still be painted with no network", async () => {
|
||||
const stored = await media.store("slide/a", "http://files.example.test/a?sig=1");
|
||||
|
||||
// A local URL for bytes this page holds — never the delivery URL, which needs the network.
|
||||
expect(stored).toMatch(/^blob:/u);
|
||||
expect(stored).not.toContain("files.example.test");
|
||||
|
||||
reachable = false;
|
||||
expect(await media.find("slide/a")).toBe(stored);
|
||||
expect(await media.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("reports nothing as cached that it does not actually hold", async () => {
|
||||
expect(await media.store("slide/a", "http://files.example.test/missing?sig=1")).toBeUndefined();
|
||||
reachable = false;
|
||||
expect(await media.store("slide/b", "http://files.example.test/b?sig=1")).toBeUndefined();
|
||||
|
||||
expect(await media.find("slide/a")).toBeUndefined();
|
||||
expect(await media.find("slide/b")).toBeUndefined();
|
||||
expect(await media.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("gives back the URLs it replaces, prunes, and holds when the display stops", async () => {
|
||||
for (const key of ["slide/a", "slide/b", "slide/c"]) {
|
||||
await media.store(key, `http://files.example.test/${key}?sig=1`);
|
||||
}
|
||||
await media.store("slide/a", "http://files.example.test/slide/a?sig=2");
|
||||
expect(live.size).toBe(3);
|
||||
|
||||
await media.prune(["slide/a", "slide/b"]);
|
||||
expect(live.size).toBe(2);
|
||||
expect(await media.find("slide/c")).toBeUndefined();
|
||||
expect(await media.count()).toBe(2);
|
||||
|
||||
media.release();
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type OfflineSnapshot,
|
||||
resolveOffline,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../src/browser/offline.js";
|
||||
import type { SceneSelection } from "../../src/browser/scene-request.js";
|
||||
import {
|
||||
createBrowserMediaCache,
|
||||
createMemoryMediaCache,
|
||||
createMemoryStore,
|
||||
signageMediaKey,
|
||||
@@ -294,6 +295,98 @@ describe("offline continuation", () => {
|
||||
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
|
||||
expect(JSON.stringify(persisted)).not.toContain("https://");
|
||||
});
|
||||
|
||||
/**
|
||||
* Stage 0's real timeline: **one** provider-source slot, which the server answers with the next
|
||||
* image of the provider's rotation on every play. Offline, replaying only that slot's newest answer
|
||||
* meant replaying the image already on screen — every boundary "advanced" to the same picture.
|
||||
*/
|
||||
function rotatingSnapshot(newestFirstItems: readonly string[]): OfflineSnapshot {
|
||||
const selections = newestFirstItems.map((item, index) =>
|
||||
selection({ id: `sel-${index}`, itemId: `item-${item}`, slotId: "slot-source" }),
|
||||
);
|
||||
return snapshot({
|
||||
playback: {
|
||||
blackout: false,
|
||||
currentSelectionId: "sel-0",
|
||||
currentSlotId: "slot-source",
|
||||
deadlineAtMs: 5000,
|
||||
pendingTimelineId: null,
|
||||
remainingMs: 5000,
|
||||
revision: 3,
|
||||
startedAtMs: 0,
|
||||
transport: "playing",
|
||||
},
|
||||
selections,
|
||||
timeline: {
|
||||
eligibleFromMs: null,
|
||||
eligibleUntilMs: null,
|
||||
id: "timeline-1",
|
||||
revision: 2,
|
||||
slots: [slot("slot-source", 0)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function itemAt(persisted: OfflineSnapshot, atMs: number): string | undefined {
|
||||
const { resolution } = resolveOffline({ atMs, snapshot: persisted });
|
||||
return resolution.kind === "selection" ? resolution.selection.itemId : undefined;
|
||||
}
|
||||
|
||||
it("continues a source slot's rotation through the answers it already has", () => {
|
||||
// On screen: B. Before it, A, then B, then A — the provider's two-image rotation.
|
||||
const persisted = rotatingSnapshot(["b", "a", "b", "a"]);
|
||||
|
||||
expect(itemAt(persisted, 2500)).toBe("item-b");
|
||||
expect([7500, 12_500, 17_500, 22_500, 27_500].map((at) => itemAt(persisted, at))).toEqual([
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the provider's own order across a longer rotation", () => {
|
||||
// Played C, A, B, C, A, B — B is on screen, so C comes next, then A, then B.
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
|
||||
expect([7500, 12_500, 17_500, 22_500].map((at) => itemAt(persisted, at))).toEqual([
|
||||
"item-c",
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("catches a rotation up across a week in one evaluation", () => {
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
// Mid-way through the slot's 120,961st play since the anchor.
|
||||
const week = 7 * 24 * 60 * 60 * 1000 + 7500;
|
||||
const started = Date.now();
|
||||
|
||||
// A three-image rotation that resumes at C shows C on every play numbered 1 mod 3.
|
||||
expect(itemAt(persisted, week)).toBe("item-c");
|
||||
expect(itemAt(persisted, week + 5000)).toBe("item-a");
|
||||
expect(Date.now() - started).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("skips an image it cannot render and keeps rotating through the rest", () => {
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
const unusable = new Set(["sel-1"]);
|
||||
|
||||
const items = [7500, 12_500, 17_500].map((atMs) => {
|
||||
const { resolution } = resolveOffline({
|
||||
atMs,
|
||||
snapshot: persisted,
|
||||
unusableSelectionIds: unusable,
|
||||
});
|
||||
return resolution.kind === "selection" ? resolution.selection.itemId : undefined;
|
||||
});
|
||||
|
||||
// A's newest answer is unusable, so A is not shown — and no older answer of A is dug up instead.
|
||||
expect(items).toEqual(["item-c", "item-b", "item-c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a display that loses its network", () => {
|
||||
@@ -574,3 +667,281 @@ describe("a display that loses its network", () => {
|
||||
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 two timeline shapes a pair of alternating images can take. Two pinned slots, one image
|
||||
* each; or Stage 0's real one — a single provider-source slot the server answers with the next
|
||||
* image of the rotation on every play. */
|
||||
type TimelineShape = "two pinned slots" | "one provider-source slot";
|
||||
|
||||
/** 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, shape: TimelineShape): DisplayChannelStateView {
|
||||
const cycle = Math.floor(atMs / SLOT_MS);
|
||||
const slide = (c: number) => (c % 2 === 0 ? "a" : "b");
|
||||
const slotFor = (c: number) =>
|
||||
shape === "two pinned slots" ? `slot-${slide(c)}` : "slot-source";
|
||||
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: slotFor(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:
|
||||
shape === "two pinned slots"
|
||||
? [
|
||||
slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }),
|
||||
slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }),
|
||||
]
|
||||
: [slot("slot-source", 0, { estimatedDurationMs: SLOT_MS })],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
// Wi-Fi off on a plain-http LAN origin: no API, no storage, and no Cache Storage.
|
||||
{ outage: "network", shape: "two pinned slots" },
|
||||
{ outage: "network", shape: "one provider-source slot" },
|
||||
// The API process stopped while the storage server stays up.
|
||||
{ outage: "api", shape: "one provider-source slot" },
|
||||
] as const)("keeps cycling through its cached images with the $outage down: $shape", async ({
|
||||
outage,
|
||||
shape,
|
||||
}) => {
|
||||
const api = { reachable: true };
|
||||
const storage = { 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 (!api.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse(serverState(clock.now(), shape));
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: /^viewers\/.*\/heartbeat$/u,
|
||||
respond: () => {
|
||||
if (!api.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse({});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// A plain-http LAN origin: no `caches`, but `fetch` — which needs the storage server.
|
||||
const media = createBrowserMediaCache({
|
||||
fetch: async () => {
|
||||
if (!storage.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 storage.
|
||||
decodeImage: (image) =>
|
||||
(image.getAttribute("src") ?? "").startsWith("blob:") || storage.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 outage lasts six slides.
|
||||
api.reachable = false;
|
||||
storage.reachable = outage !== "network";
|
||||
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");
|
||||
|
||||
// Everything returns. The server is authoritative again with no reload.
|
||||
api.reachable = true;
|
||||
storage.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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user