Compare commits
14
Commits
97de441b5f
..
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd06d63007 | ||
|
|
49fdd446ef | ||
|
|
c04506ba7b | ||
|
|
c1213dad08 | ||
|
|
ba4821ec63 | ||
|
|
41dbefe552 | ||
|
|
9aa82068f1 | ||
|
|
51d9085cdb | ||
|
|
ab5b02960a | ||
|
|
5f52334664 | ||
|
|
b479ae9296 | ||
|
|
f8b131f4ed | ||
|
|
649114ba1d | ||
|
|
073fbc5855 |
@@ -3,7 +3,7 @@ name: CI
|
|||||||
# Signage is a standalone repository: every job installs it with --frozen-lockfile from its own
|
# Signage is a standalone repository: every job installs it with --frozen-lockfile from its own
|
||||||
# pnpm-lock.yaml and takes Ekklesia's module-development packages (@ekklesia/*) from Ekklesia's
|
# pnpm-lock.yaml and takes Ekklesia's module-development packages (@ekklesia/*) from Ekklesia's
|
||||||
# public package registry (.npmrc; platform ADRs 0053 and 0054). Only the integration job fetches
|
# public package registry (.npmrc; platform ADRs 0053 and 0054). Only the integration job fetches
|
||||||
# Core source: one immutable commit through the pinned Gitea pull ref in ci/platform.env, used as the
|
# Core source: one immutable commit through the pinned Gitea ref in ci/platform.env, used as the
|
||||||
# generic deployment harness and image source, never as a workspace dependency.
|
# generic deployment harness and image source, never as a workspace dependency.
|
||||||
#
|
#
|
||||||
# The same trust rules as the platform's own CI (docs/development/security.md there):
|
# The same trust rules as the platform's own CI (docs/development/security.md there):
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.4 is feature-complete for its declared scope, and Signage has run in production since
|
Version 0.1.5 is feature-complete for its declared scope, and Signage has run in production since
|
||||||
0.1.1. What exists today:
|
0.1.1. What exists today:
|
||||||
|
|
||||||
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
|
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
|
||||||
|
|||||||
+2
-2
@@ -40,9 +40,9 @@ check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \
|
|||||||
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
|
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
|
||||||
check 'grep -q "EKKLESIA_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \
|
check 'grep -q "EKKLESIA_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \
|
||||||
"the integration delegates its external package to the platform smoke"
|
"the integration delegates its external package to the platform smoke"
|
||||||
check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$root/ci/platform.env" && grep -Eq "^EKKLESIA_PLATFORM_COMMIT=[0-9a-f]{40}$" "$root/ci/platform.env"' \
|
check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main|tags/v[0-9]+\.[0-9]+\.[0-9]+)$" "$root/ci/platform.env" && grep -Eq "^EKKLESIA_PLATFORM_COMMIT=[0-9a-f]{40}$" "$root/ci/platform.env"' \
|
||||||
"the platform harness is pinned by full ref and commit"
|
"the platform harness is pinned by full ref and commit"
|
||||||
check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
|
check 'grep -qF "rev-parse \"refs/remotes/pinned^{commit}\"" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
|
||||||
"the fetch verifies that the pinned ref resolves to the pinned commit"
|
"the fetch verifies that the pinned ref resolves to the pinned commit"
|
||||||
|
|
||||||
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
|
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ git -C "$out" init --quiet
|
|||||||
git -C "$out" remote add origin "$repository"
|
git -C "$out" remote add origin "$repository"
|
||||||
git -C "$out" fetch --quiet --no-tags --depth=1 origin "+${ref}:refs/remotes/pinned" ||
|
git -C "$out" fetch --quiet --no-tags --depth=1 origin "+${ref}:refs/remotes/pinned" ||
|
||||||
fail "cannot fetch the pinned platform ref."
|
fail "cannot fetch the pinned platform ref."
|
||||||
[[ "$(git -C "$out" rev-parse refs/remotes/pinned)" == "$commit" ]] ||
|
[[ "$(git -C "$out" rev-parse "refs/remotes/pinned^{commit}")" == "$commit" ]] ||
|
||||||
fail "the pinned ref does not resolve to EKKLESIA_PLATFORM_COMMIT."
|
fail "the pinned ref does not resolve to EKKLESIA_PLATFORM_COMMIT."
|
||||||
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
|
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
|
||||||
rm -rf "$out/.git"
|
rm -rf "$out/.git"
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,6 @@
|
|||||||
# The companion Core harness Signage uses to install and run its real external package. The pull ref
|
# The companion Core harness Signage uses to install and run its real external package: a released
|
||||||
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
|
# Core, by its tag. A pull request's ref (refs/pull/N/head) is used only while Signage depends on a
|
||||||
|
# Core change still in review. The full commit pin makes the checkout immutable either way.
|
||||||
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/tags/v0.2.5
|
||||||
EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a
|
EKKLESIA_PLATFORM_COMMIT=437720f2286e962829f2ec8c1c6137ea24f87b9f
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"id": "signage",
|
"id": "signage",
|
||||||
"name": "Signage",
|
"name": "Signage",
|
||||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.2.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.2.0"
|
"moduleSdk": "^0.2.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/signage",
|
"name": "@ekklesia/signage",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Generated
+9
-9
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.4",
|
version: "0.1.5",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function createSignageModuleRuntime(
|
|||||||
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
|
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
|
||||||
database: { abi: 1 },
|
database: { abi: 1 },
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.4",
|
version: "0.1.5",
|
||||||
|
|
||||||
register({ capabilities, config, http, realtime }) {
|
register({ capabilities, config, http, realtime }) {
|
||||||
const settings = readSignageSettings(config);
|
const settings = readSignageSettings(config);
|
||||||
@@ -192,7 +192,7 @@ export function createSignageModuleRuntime(
|
|||||||
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||||
return Response.json({
|
return Response.json({
|
||||||
crossfadeMs: current.crossfadeMs,
|
crossfadeMs: current.crossfadeMs,
|
||||||
moduleVersion: "0.1.4",
|
moduleVersion: "0.1.5",
|
||||||
nativeImageDurationMs: current.nativeImageDurationMs,
|
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||||
nativePresentation: current.nativePresentation,
|
nativePresentation: current.nativePresentation,
|
||||||
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.4",
|
version: "0.1.5",
|
||||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
|
|||||||
/** The settings payload every screen loads first. */
|
/** The settings payload every screen loads first. */
|
||||||
export const FAKE_SETTINGS = Object.freeze({
|
export const FAKE_SETTINGS = Object.freeze({
|
||||||
crossfadeMs: 1000,
|
crossfadeMs: 1000,
|
||||||
moduleVersion: "0.1.4",
|
moduleVersion: "0.1.5",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
|
|||||||
expect(manifest.manifestVersion).toBe(8);
|
expect(manifest.manifestVersion).toBe(8);
|
||||||
expect(manifest.id).toBe("signage");
|
expect(manifest.id).toBe("signage");
|
||||||
expect(manifest.name).toBe("Signage");
|
expect(manifest.name).toBe("Signage");
|
||||||
expect(manifest.version).toBe("0.1.4");
|
expect(manifest.version).toBe("0.1.5");
|
||||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
|
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ describe("Signage module manifest", () => {
|
|||||||
describe("Signage server runtime", () => {
|
describe("Signage server runtime", () => {
|
||||||
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||||
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
|
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
|
||||||
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.4" });
|
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.5" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||||
@@ -251,7 +251,7 @@ describe("Signage server runtime", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
crossfadeMs: 400,
|
crossfadeMs: 400,
|
||||||
moduleVersion: "0.1.4",
|
moduleVersion: "0.1.5",
|
||||||
nativeImageDurationMs: 12_000,
|
nativeImageDurationMs: 12_000,
|
||||||
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
||||||
rendererInputVersion: "1.0.0",
|
rendererInputVersion: "1.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user