Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 41dbefe552 docs(claude): file an issue for what you find outside the task
CI / lint (pull_request) Successful in 20s
CI / typecheck (pull_request) Successful in 20s
CI / test (pull_request) Successful in 31s
CI / build (pull_request) Successful in 24s
CI / package (pull_request) Successful in 28s
CI / integration (pull_request) Successful in 8m46s
Findings that only live in a chat reply get lost. Agents now open a
descriptive issue in the repository a problem belongs to (after
searching for an existing one), mark it found-by-claude, leave it
unassigned, and list what they filed when they report back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:58:32 -05:00
Zayin_Admin 9aa82068f1 Merge pull request 'fix: Stage 0 Signage production readiness' (#18) from stage0/signage-production-readiness into main
CI / lint (push) Successful in 18s
CI / typecheck (push) Successful in 19s
CI / test (push) Successful in 26s
CI / build (push) Successful in 23s
CI / package (push) Successful in 23s
CI / integration (push) Successful in 8m46s
Reviewed-on: #18
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:21:28 +00:00
ekklesiaandClaude Opus 5.5 51d9085cdb fix(display): drop the previous assignment's frame when the new one has nothing
CI / typecheck (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 28s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
Moved from a group that was playing to one with nothing to show, a
display adopted the new assignment but kept the old group's last image
on screen indefinitely. With nothing renderable, the controller keeps
whatever frame is up, which is right for a gap in the same assignment
and wrong for a frame from an assignment the display no longer has. The
same early return also skipped persisting, so an outage or a restart
resumed the old group's content from local state.

The controller now records which assignment the frame on screen came
from. When the server says the current assignment has nothing, a frame
from another assignment is taken down (a new stage clear(), which also
abandons any transition still preparing) and the slate shows; and the
new assignment's state is persisted if the stored one belongs to a
different assignment. A gap within the same assignment still keeps its
last good frame, and every path with content is unchanged.

Regression tests: populated-group to empty-group reassignment drops the
old image and shows the slate; and a restart with no server after that
move does not resume the old group's content.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:38:42 -05:00
ekklesiaandClaude Opus 5.5 ab5b02960a fix(display): continue a source slot's rotation while offline
CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 28s
CI / test (pull_request) Successful in 29s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
Offline continuation replayed each slot's newest cached answer. A
provider-source slot is answered with the provider's next item on every
play, so its newest answer is the image already on screen: on a timeline
of one source slot, every offline boundary "advanced" to the same
picture. Stage 0's real timeline is exactly that, which is why the wall
froze whether the network or only the API went away, and resumed the
moment the server answered.

Each slot's cached answers are now its observed rotation (one per item,
oldest-played first), and the k-th play of the slot since the anchor
shows the k-th entry. The play count is arithmetic, skipping whole
passes wherever the eligible set is stable, so a week offline is still
one evaluation. Where a play starts is unchanged and still decided by
advanceCursor alone; a pinned slot is a rotation of one and behaves as
before. Nothing is invented: only answers the server already resolved
are replayed, and an item whose newest answer is unusable is skipped.

Regression tests: the resolver on a one-slot rotation of two and three
images, across a week, and with an unusable image; and the controller
on the real shape for six slide intervals with the network down and
with only the API down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:14:35 -05:00
ekklesiaandClaude Opus 5.5 5f52334664 fix(display): keep cached image bytes when the browser has no Cache Storage
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]>
2026-10-13 19:42:00 -05:00
ekklesiaandClaude Opus 5.5 b479ae9296 ci: follow the platform harness pin to Zayin/Ekklesia#92's current head
CI / lint (pull_request) Successful in 25s
CI / typecheck (pull_request) Successful in 17s
CI / test (pull_request) Successful in 30s
CI / build (pull_request) Successful in 25s
CI / package (pull_request) Successful in 20s
CI / integration (pull_request) Successful in 8m33s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:30:25 -05:00
ekklesiaandClaude Opus 5.5 f8b131f4ed docs(adr): point platform cross-references at the repository's current location
CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 24s
CI / build (pull_request) Successful in 36s
CI / test (pull_request) Successful in 38s
CI / package (pull_request) Successful in 29s
CI / integration (pull_request) Failing after 1m6s
Link targets only: the Ekklesia repository moved from
git.riverofglorymn.org/River_of_Glory_Church to git.zayin.net/Zayin, and the
old host no longer answers. No decision text changes, which is the
cross-reference correction the ADR rules allow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:05 -05:00
ekklesiaandClaude Opus 5.5 649114ba1d ci: pin the platform harness that re-pairs the kiosk after revocation
CI / lint (pull_request) Successful in 23s
CI / typecheck (pull_request) Successful in 22s
CI / build (pull_request) Successful in 27s
CI / test (pull_request) Successful in 50s
CI / package (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 8m38s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:02:49 -05:00
ekklesiaandClaude Opus 5.5 073fbc5855 build: fetch Ekklesia packages from the registry's current Zayin location
CI / typecheck (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 28s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Canceled after 4m3s
The @ekklesia package registry moved with the repositories to
git.zayin.net/api/packages/Zayin/npm/. The old River_of_Glory_Church address
now answers 502, so every CI job failed at install (Signage run 271). Only the
address changes: a frozen install from an empty store against the new location
verifies every pinned integrity hash, so the bytes are identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:57:38 -05:00
14 changed files with 821 additions and 29 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Ekklesia's module-development packages (@ekklesia/*) come from Ekklesia's public package registry # 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. # (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/
+7
View File
@@ -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. 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, - 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. 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 ## Verification
+1 -1
View File
@@ -2,4 +2,4 @@
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. # 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_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git
EKKLESIA_PLATFORM_REF=refs/pull/92/head EKKLESIA_PLATFORM_REF=refs/pull/92/head
EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a EKKLESIA_PLATFORM_COMMIT=5e9433d96b413117374a6132d89400867414c2c8
@@ -2,8 +2,8 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-27 - Date: 2026-08-27
- Related: Ekklesia [ADR 0006](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.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (versioned capabilities), [ADR 0041](https://git.zayin.net/Zayin/Ekklesia)
(browser contributions) (browser contributions)
## Context ## Context
@@ -3,7 +3,7 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-27 - Date: 2026-08-27
- Related: [0005](0005-authoritative-playback.md), Ekklesia - 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 ## Context
@@ -3,8 +3,8 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-27 - Date: 2026-08-27
- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia - 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 0030](https://git.zayin.net/Zayin/Ekklesia) (host-provided storage),
[ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies) [ADR 0042](https://git.zayin.net/Zayin/Ekklesia) (streaming bodies)
## Context ## Context
+9 -9
View File
@@ -136,13 +136,13 @@ packages:
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@ekklesia/[email protected]': '@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]': '@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]': '@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: peerDependencies:
drizzle-orm: '>=0.45.2 <0.46.0' drizzle-orm: '>=0.45.2 <0.46.0'
peerDependenciesMeta: peerDependenciesMeta:
@@ -150,24 +150,24 @@ packages:
optional: true optional: true
'@ekklesia/[email protected]': '@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 hasBin: true
'@ekklesia/[email protected]': '@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]': '@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 hasBin: true
'@ekklesia/[email protected]': '@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]': '@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]': '@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: peerDependencies:
react: ^19.2.0 react: ^19.2.0
+115 -7
View File
@@ -1,6 +1,10 @@
import { import {
advanceCursor, advanceCursor,
eligibleSlots,
firstEligibilityBoundary,
nextTimingBoundary, nextTimingBoundary,
TIMING_TRANSITION_LIMIT,
type TimingCursor,
type TimingSlot, type TimingSlot,
type TimingTimeline, type TimingTimeline,
} from "../shared/playback-timing.js"; } from "../shared/playback-timing.js";
@@ -111,17 +115,110 @@ function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet
return selection.availableUntilMs === null || atMs < selection.availableUntilMs; 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. */ * The answers one slot has already had, as the rotation they came in: one per item, oldest-played
function cachedForSlot( * 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, snapshot: OfflineSnapshot,
slotId: string, slotId: string,
atMs: number, atMs: number,
unusable: ReadonlySet<string>, 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 { ): SceneSelection | undefined {
return snapshot.selections.find( const rotation = rotationForSlot(snapshot, slotId, atMs, unusable);
(selection) => selection.slotId === slotId && isUsable(selection, 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 { export interface OfflineEvaluation {
@@ -179,6 +276,11 @@ export function resolveOffline(input: {
} }
const timing = timingTimelineOf(timeline); const timing = timingTimelineOf(timeline);
const anchor: TimingCursor = {
deadlineAtMs: snapshot.playback.deadlineAtMs,
slotId: snapshot.playback.currentSlotId,
startedAtMs: snapshot.playback.startedAtMs,
};
let cursor = { let cursor = {
deadlineAtMs: snapshot.playback.deadlineAtMs, deadlineAtMs: snapshot.playback.deadlineAtMs,
slotId: snapshot.playback.currentSlotId, 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) { if (cached) {
const nextCursor = { const nextCursor = {
deadlineAtMs: advance.deadlineAtMs, deadlineAtMs: advance.deadlineAtMs,
+17
View File
@@ -80,6 +80,9 @@ export interface SceneStage {
setSlate(on: boolean): void; setSlate(on: boolean): void;
/** What is on screen now, or `undefined` if nothing ever was. */ /** What is on screen now, or `undefined` if nothing ever was. */
currentKey(): string | undefined; 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; setCrossfade(milliseconds: number): void;
destroy(): Promise<void>; destroy(): Promise<void>;
} }
@@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage {
} }
return { 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, currentKey: () => layers[currentIndex]?.key,
async destroy(): Promise<void> { async destroy(): Promise<void> {
+63 -3
View File
@@ -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 { export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
const maybeCaches = (scope as { caches?: CacheStorage }).caches; const maybeCaches = (scope as { caches?: CacheStorage }).caches;
const fetchFn = (scope as { fetch?: typeof fetch }).fetch; const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
if (!maybeCaches || !fetchFn) { if (!fetchFn) {
return createMemoryMediaCache(); return createMemoryMediaCache();
} }
if (!maybeCaches) {
return createPageMediaCache(fetchFn);
}
const caches: CacheStorage = maybeCaches; const caches: CacheStorage = maybeCaches;
async function open(): Promise<Cache | undefined> { async function open(): Promise<Cache | undefined> {
+22 -2
View File
@@ -118,6 +118,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
let stopped = false; let stopped = false;
let online = false; let online = false;
let renderedSelectionId: string | undefined; 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 cachedAssetCount = 0;
let lastError: { code: string; detail: string | null } | undefined; let lastError: { code: string; detail: string | null } | undefined;
let appliedRevision = -1; 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 { function recordError(code: string, detail: string | null = null): void {
lastError = { code, detail }; lastError = { code, detail };
report(); report();
@@ -261,6 +268,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
return; return;
} }
renderedSelectionId = selection.id; renderedSelectionId = selection.id;
frameAssignment = assignmentOf(state.channel);
if (lastError !== undefined) { if (lastError !== undefined) {
lastError = undefined; lastError = undefined;
} }
@@ -287,11 +295,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
const target = state.staticHold ?? state.current; const target = state.staticHold ?? state.current;
if (target === null) { if (target === null) {
// Nothing renderable from the server. If something good is already on screen it stays; only a const assignment = assignmentOf(state.channel);
// display with nothing at all shows the slate. // 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) { if (stage.currentKey() === undefined) {
stage.setSlate(true); 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(); report();
return; return;
} }
@@ -428,6 +447,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
} }
if (outcome.kind !== "superseded") { if (outcome.kind !== "superseded") {
renderedSelectionId = selection.id; renderedSelectionId = selection.id;
frameAssignment = assignmentOf(snapshot.channel);
} }
} }
+122
View File
@@ -546,6 +546,128 @@ describe("display controller", () => {
await controller.stop(); 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 () => { it("registers again, exactly once, when it was removed from the roster", async () => {
let sessions = 0; let sessions = 0;
let subscriptions = 0; let subscriptions = 0;
+87
View File
@@ -117,3 +117,90 @@ describe("Signage's media cache and object URLs", () => {
expect(live.size).toBe(0); 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);
});
});
+372 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
type OfflineSnapshot, type OfflineSnapshot,
resolveOffline, resolveOffline,
@@ -8,6 +8,7 @@ import {
} from "../../src/browser/offline.js"; } from "../../src/browser/offline.js";
import type { SceneSelection } from "../../src/browser/scene-request.js"; import type { SceneSelection } from "../../src/browser/scene-request.js";
import { import {
createBrowserMediaCache,
createMemoryMediaCache, createMemoryMediaCache,
createMemoryStore, createMemoryStore,
signageMediaKey, signageMediaKey,
@@ -294,6 +295,98 @@ describe("offline continuation", () => {
expect(JSON.stringify(persisted)).not.toContain("sig=secret"); expect(JSON.stringify(persisted)).not.toContain("sig=secret");
expect(JSON.stringify(persisted)).not.toContain("https://"); 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", () => { describe("a display that loses its network", () => {
@@ -574,3 +667,281 @@ describe("a display that loses its network", () => {
await controller.stop(); 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();
});
});