Compare commits
19
Commits
776540ff78
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ae907f59 | ||
|
|
855b5f9f54 | ||
|
|
e6c6553afc | ||
|
|
525335e6dd | ||
|
|
e3c5e546eb | ||
|
|
607f375809 | ||
|
|
1c6c9ccc1f | ||
|
|
ab4142fb95 | ||
|
|
bf4e98fa30 | ||
|
|
fbc8af7510 | ||
|
|
518fd101a2 | ||
|
|
27ee279f28 | ||
|
|
f1b7d67aa5 | ||
|
|
458043280a | ||
|
|
6869ae433a | ||
|
|
cbf2a8ee39 | ||
|
|
cfa4628f8c | ||
|
|
914034dda0 | ||
|
|
3073baeb8b |
@@ -0,0 +1,217 @@
|
||||
name: CI
|
||||
|
||||
# Signage builds, tests, and runs only inside an Ekklesia workspace, so every job first assembles it
|
||||
# into the platform commit pinned in ci/platform.env (ci/assemble.sh) and installs that workspace
|
||||
# with --frozen-lockfile from ci/pnpm-lock.yaml.
|
||||
#
|
||||
# The same trust rules as the platform's own CI (docs/development/security.md there):
|
||||
# * every action is pinned by a full commit SHA;
|
||||
# * every checkout sets `persist-credentials: false`, and nothing in this workflow pushes;
|
||||
# * no repository, organization, or instance secret is referenced;
|
||||
# * there is no `pull_request_target`;
|
||||
# * the self-hosted runner executes trusted, reviewed code only.
|
||||
#
|
||||
# The server is Gitea 1.24, which ignores `permissions`, `concurrency`, and `timeout-minutes`
|
||||
# (https://docs.gitea.com/1.24/usage/actions/comparison). They are declared so the workflow is already
|
||||
# correct on a release that honours them; on 1.24 they bound nothing, and `permissions` does not
|
||||
# restrict the job token.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Nothing here pushes, so the job token is never needed after checkout and must not be left
|
||||
# in .git/config for a later step to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assemble Signage into the pinned platform
|
||||
run: bash ci/assemble.sh
|
||||
|
||||
- name: Set up Node.js at the platform's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: build/workspace/.node-version
|
||||
|
||||
- name: Install the assembled workspace (frozen lockfile)
|
||||
working-directory: build/workspace
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
working-directory: build/workspace
|
||||
run: pnpm --filter @ekklesia/signage lint
|
||||
|
||||
- name: Check that CI cleans up every stack it starts
|
||||
run: bash ci/check-cleanup.sh
|
||||
|
||||
typecheck:
|
||||
name: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Nothing here pushes, so the job token is never needed after checkout and must not be left
|
||||
# in .git/config for a later step to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assemble Signage into the pinned platform
|
||||
run: bash ci/assemble.sh
|
||||
|
||||
- name: Set up Node.js at the platform's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: build/workspace/.node-version
|
||||
|
||||
- name: Install the assembled workspace (frozen lockfile)
|
||||
working-directory: build/workspace
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the platform packages Signage depends on
|
||||
working-directory: build/workspace
|
||||
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
|
||||
|
||||
- name: Typecheck the server, browser, and browser-test projects
|
||||
working-directory: build/workspace
|
||||
run: pnpm --filter @ekklesia/signage typecheck
|
||||
|
||||
test:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Nothing here pushes, so the job token is never needed after checkout and must not be left
|
||||
# in .git/config for a later step to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assemble Signage into the pinned platform
|
||||
run: bash ci/assemble.sh
|
||||
|
||||
- name: Set up Node.js at the platform's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: build/workspace/.node-version
|
||||
|
||||
- name: Install the assembled workspace (frozen lockfile)
|
||||
working-directory: build/workspace
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the platform packages Signage depends on
|
||||
working-directory: build/workspace
|
||||
run: pnpm exec turbo run build --filter='@ekklesia/signage^...'
|
||||
|
||||
- name: Unit, browser, and display contract tests
|
||||
working-directory: build/workspace
|
||||
run: pnpm --filter @ekklesia/signage test
|
||||
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Nothing here pushes, so the job token is never needed after checkout and must not be left
|
||||
# in .git/config for a later step to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assemble Signage into the pinned platform
|
||||
run: bash ci/assemble.sh
|
||||
|
||||
- name: Set up Node.js at the platform's version
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: build/workspace/.node-version
|
||||
|
||||
- name: Install the assembled workspace (frozen lockfile)
|
||||
working-directory: build/workspace
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# The same gates the platform's image build runs against exactly this module set: its boundary
|
||||
# rules (which apply to modules), the manifest, and the migrations.
|
||||
- name: Platform gates (boundaries, module manifest, migrations)
|
||||
working-directory: build/workspace
|
||||
run: |
|
||||
pnpm boundaries
|
||||
pnpm modules:validate
|
||||
pnpm migrations:validate
|
||||
|
||||
- name: Build Signage and the platform it depends on
|
||||
working-directory: build/workspace
|
||||
run: pnpm exec turbo run build --filter='@ekklesia/signage...'
|
||||
|
||||
# Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the
|
||||
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs
|
||||
# against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A
|
||||
# disposable database and object store with generated secrets; nothing is pushed or logged in to.
|
||||
integration:
|
||||
name: integration
|
||||
needs:
|
||||
- test
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
# Names this job's stacks, so its final step removes exactly these (the platform's
|
||||
# scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: signage-integration
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assemble Signage into the pinned platform
|
||||
run: bash ci/assemble.sh
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash build/workspace/scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Build the image and run the display scenarios
|
||||
run: bash ci/integration.sh
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it. If assembly failed, nothing
|
||||
# was started.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f build/workspace/scripts/ci-sweep.sh ]; then
|
||||
bash build/workspace/scripts/ci-sweep.sh --job
|
||||
fi
|
||||
@@ -4,3 +4,4 @@ dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
build/
|
||||
|
||||
@@ -16,7 +16,8 @@ Ekklesia and vendors none of its SDKs.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
|
||||
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
|
||||
0.1.1. What exists today:
|
||||
|
||||
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
||||
one logical storage bucket.
|
||||
@@ -34,10 +35,10 @@ Version 0.1.1 is feature-complete for its declared scope and unreleased. What ex
|
||||
renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate
|
||||
blackout, and offline continuation from this display's own persisted state.
|
||||
|
||||
Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is
|
||||
planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox
|
||||
are out of scope; so is any content module — Signage ships with no announcements, scripture, or
|
||||
trivia, and cannot render any until one is installed.
|
||||
No cutover from the legacy Announcements container has happened or is planned by this repository.
|
||||
Video, audio, MQTT, Home Assistant, Companion, and a notification outbox are out of scope; so is
|
||||
any content module — Signage ships with no announcements, scripture, or trivia, and cannot render
|
||||
any until one is installed.
|
||||
|
||||
### Offline cold start is not yet possible
|
||||
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assembles Signage into the Ekklesia platform it is tested against: the platform at the commit pinned
|
||||
# in ci/platform.env, this checkout at modules/signage, and ci/pnpm-lock.yaml as the workspace
|
||||
# lockfile. Signage cannot build or run outside an Ekklesia workspace, so every CI job starts here.
|
||||
#
|
||||
# bash ci/assemble.sh [--allow-missing-lock] [output directory]
|
||||
#
|
||||
# The output directory (default build/workspace) is deleted and recreated. The platform is fetched
|
||||
# by its `main` branch and the pinned commit must be on it, so CI only ever runs reviewed platform
|
||||
# code; the checkout is then by commit, so nothing after it depends on a ref the network can move.
|
||||
# Every .git directory is removed afterwards.
|
||||
#
|
||||
# --allow-missing-lock keeps the platform's own lockfile. Only ci/update-lock.sh uses it.
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
allow_missing_lock=0
|
||||
if [[ "${1:-}" == "--allow-missing-lock" ]]; then
|
||||
allow_missing_lock=1
|
||||
shift
|
||||
fi
|
||||
out="${1:-$root/build/workspace}"
|
||||
|
||||
fail() {
|
||||
echo "assemble: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for command in git tar; do
|
||||
command -v "$command" >/dev/null || fail "$command is required."
|
||||
done
|
||||
|
||||
# Read the two pins without sourcing the file: it is data, not a script.
|
||||
pin() {
|
||||
local value
|
||||
value="$(sed -n "s/^$1=//p" "$root/ci/platform.env")"
|
||||
[[ -n "$value" ]] || fail "ci/platform.env has no $1."
|
||||
printf '%s' "$value"
|
||||
}
|
||||
repository="$(pin EKKLESIA_PLATFORM_REPOSITORY)"
|
||||
commit="$(pin EKKLESIA_PLATFORM_COMMIT)"
|
||||
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "EKKLESIA_PLATFORM_COMMIT must be a full commit SHA."
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$(dirname -- "$out")"
|
||||
git init --quiet "$out"
|
||||
git -C "$out" remote add origin "$repository"
|
||||
git -C "$out" fetch --quiet --no-tags origin "+refs/heads/main:refs/remotes/origin/main" ||
|
||||
fail "cannot fetch the platform's main from ${repository}."
|
||||
git -C "$out" cat-file -e "${commit}^{commit}" 2>/dev/null ||
|
||||
fail "the pinned platform commit ${commit} is not on the platform's main."
|
||||
git -C "$out" merge-base --is-ancestor "$commit" refs/remotes/origin/main ||
|
||||
fail "the pinned platform commit ${commit} is not on the platform's main."
|
||||
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
|
||||
[[ "$(git -C "$out" rev-parse HEAD)" == "$commit" ]] || fail "the platform did not check out at ${commit}."
|
||||
rm -rf "$out/.git"
|
||||
echo "assemble: platform ${commit}"
|
||||
|
||||
[[ ! -e "$out/modules/signage" ]] || fail "the platform checkout already has modules/signage."
|
||||
mkdir -p "$out/modules/signage"
|
||||
# Exactly the files this checkout tracks, as committed or as changed in the working tree. Nothing
|
||||
# installed, built, or ignored, and not the CI scaffolding, which is not part of the module.
|
||||
git -C "$root" ls-files -z --cached --others --exclude-standard -- . ':!ci' ':!.gitea' |
|
||||
tar -C "$root" --null --files-from=- --create --file=- |
|
||||
tar -C "$out/modules/signage" --extract --file=-
|
||||
echo "assemble: signage $(git -C "$root" rev-parse HEAD 2>/dev/null || echo "(working tree)")"
|
||||
|
||||
if [[ -f "$root/ci/pnpm-lock.yaml" ]]; then
|
||||
cp "$root/ci/pnpm-lock.yaml" "$out/pnpm-lock.yaml"
|
||||
elif ((allow_missing_lock == 0)); then
|
||||
fail "ci/pnpm-lock.yaml is missing; run bash ci/update-lock.sh."
|
||||
fi
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that Signage's CI cleans up every stack it starts, even when the runner kills a job (the
|
||||
# platform's rule: docs/development/security.md, "Disposable stacks never outlive their job").
|
||||
#
|
||||
# bash ci/check-cleanup.sh
|
||||
#
|
||||
# The `integration` job is the one that starts Docker stacks. It must have its own
|
||||
# EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final
|
||||
# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds.
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
workflow="$root/.gitea/workflows/ci.yml"
|
||||
failures=0
|
||||
check() {
|
||||
if eval "$1"; then
|
||||
echo "ok $2"
|
||||
else
|
||||
echo "FAIL $2"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# The integration job's lines, from its key to the next job (or the end of the file).
|
||||
job="$(awk '/^ integration:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||
line_of() { grep -n -F -- "$1" <<<"$job" | head -1 | cut -d: -f1 || true; }
|
||||
|
||||
stale="$(line_of 'ci-sweep.sh --stale')"
|
||||
run="$(line_of 'bash ci/integration.sh')"
|
||||
own="$(line_of 'ci-sweep.sh --job')"
|
||||
last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)"
|
||||
|
||||
check '[[ -n "$job" ]]' "the workflow has an integration job"
|
||||
check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key"
|
||||
check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything"
|
||||
check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \
|
||||
"its last step removes its own stacks"
|
||||
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
|
||||
check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \
|
||||
"the image ci/integration.sh builds carries the CI labels"
|
||||
|
||||
((failures == 0)) || {
|
||||
echo "check-cleanup: ${failures} check(s) failed." >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# The platform's display-device scenarios this repository's CI runs against Signage, one per line
|
||||
# (scripts/lib/deploy-smoke-display.mjs in the pinned platform). Pairing a kiosk and restarting it on
|
||||
# the same profile always run first. A change that makes another scenario pass adds it here.
|
||||
two-pages
|
||||
session-blip
|
||||
stream-failure
|
||||
stream-recycle
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Signage inside Ekklesia, end to end: builds the deployment image from the assembled workspace with
|
||||
# the platform's own Dockerfile, then runs the platform's split-topology deployment smoke against it
|
||||
# with the display-device scenarios listed in ci/display-scenarios. A disposable PostgreSQL and object
|
||||
# store, generated secrets, and an explicit migration apply against that fresh database only; nothing
|
||||
# is pushed, published, or logged in to. The image is removed afterwards.
|
||||
#
|
||||
# bash ci/integration.sh (after bash ci/assemble.sh)
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
workspace="$root/build/workspace"
|
||||
[[ -f "$workspace/deploy/Dockerfile" ]] || {
|
||||
echo "integration: run bash ci/assemble.sh first." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Named and labeled as a CI stack of this run and job (the platform's scripts/lib/ci-project.sh), so
|
||||
# the workflow's final `ci-sweep.sh --job` removes it even if this script is killed before its trap.
|
||||
# shellcheck source=/dev/null
|
||||
source "$workspace/scripts/lib/ci-project.sh"
|
||||
project="$(ci_project_name signage-image)"
|
||||
image="${project}:latest"
|
||||
trap 'docker image rm --force "$image" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')"
|
||||
[[ -n "${scenarios// /}" ]] || {
|
||||
echo "integration: ci/display-scenarios lists no scenario." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$workspace/deploy/Dockerfile" --tag "$image" \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
"$workspace" >/dev/null
|
||||
echo "integration: built the deployment image with Signage; scenarios: ${scenarios}"
|
||||
|
||||
cd "$workspace"
|
||||
EKKLESIA_SMOKE_TOPOLOGY=split \
|
||||
EKKLESIA_SMOKE_IMAGE="$image" \
|
||||
EKKLESIA_SMOKE_DISPLAY_DEVICE=1 \
|
||||
EKKLESIA_SMOKE_DISPLAY_SCENARIOS="$scenarios" \
|
||||
EKKLESIA_SMOKE_WATCH_MS=120000 \
|
||||
EKKLESIA_SMOKE_RECYCLE_WATCH_MS=120000 \
|
||||
bash scripts/smoke-deploy.sh
|
||||
@@ -0,0 +1,6 @@
|
||||
# The Ekklesia platform Signage's CI builds and tests against: one reviewed commit on the platform's
|
||||
# `main`, never a moving ref. ci/assemble.sh refuses a commit that is not on `main`.
|
||||
#
|
||||
# Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both.
|
||||
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
|
||||
EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7
|
||||
Generated
+4403
File diff suppressed because it is too large
Load Diff
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates ci/pnpm-lock.yaml: the lockfile of Signage assembled into the pinned platform. Run it
|
||||
# after changing Signage's dependencies or ci/platform.env, and commit the result. CI installs with
|
||||
# --frozen-lockfile, so a stale lockfile fails there rather than resolving anything new.
|
||||
#
|
||||
# bash ci/update-lock.sh
|
||||
#
|
||||
# Uses the platform's own pinned pnpm (its packageManager field) through Corepack.
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
bash "$root/ci/assemble.sh" --allow-missing-lock "$work/workspace"
|
||||
cd "$work/workspace"
|
||||
corepack enable >/dev/null 2>&1 || true
|
||||
corepack install >/dev/null
|
||||
pnpm install --lockfile-only
|
||||
cp pnpm-lock.yaml "$root/ci/pnpm-lock.yaml"
|
||||
echo "update-lock: wrote ci/pnpm-lock.yaml"
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "signage",
|
||||
"name": "Signage",
|
||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/signage",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* How long a display waits before trying a failed request again.
|
||||
*
|
||||
* Exponential from one second to a one-minute ceiling, so a screen whose server is restarting is
|
||||
* back within seconds, and one whose server is down for an evening asks once a minute rather than
|
||||
* hammering it. The wait is jittered between half and all of that step, so a building's worth of
|
||||
* screens that lost the server at the same moment do not all come back at the same moment.
|
||||
*
|
||||
* There is no attempt limit. A television on a wall has nobody to press Retry, so giving up is the
|
||||
* one thing it must never do.
|
||||
*/
|
||||
export const DISPLAY_RETRY_BASE_MS = 1_000;
|
||||
export const DISPLAY_RETRY_MAX_MS = 60_000;
|
||||
|
||||
export function displayRetryDelayMs(attempt: number, random: () => number = Math.random): number {
|
||||
const step = Math.min(
|
||||
DISPLAY_RETRY_MAX_MS,
|
||||
DISPLAY_RETRY_BASE_MS * 2 ** Math.min(Math.max(0, attempt), 16),
|
||||
);
|
||||
return Math.round(step / 2 + (step / 2) * Math.min(1, Math.max(0, random())));
|
||||
}
|
||||
+49
-3
@@ -36,6 +36,9 @@ export interface SignageMediaCache {
|
||||
* can never leave a screen with less than it had. */
|
||||
prune(keep: readonly string[]): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
/** Gives back every local URL this cache handed out. Called when a display stops; a URL handed
|
||||
* out before this is no longer valid afterwards. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never
|
||||
@@ -86,6 +89,9 @@ export function createMemoryMediaCache(): SignageMediaCache {
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
release(): void {
|
||||
// Holds plain URLs, nothing the browser has to be given back.
|
||||
},
|
||||
store(key: string, url: string): Promise<string | undefined> {
|
||||
stored.set(key, url);
|
||||
return Promise.resolve(url);
|
||||
@@ -179,14 +185,45 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
||||
return `${origin}/${key}`;
|
||||
}
|
||||
|
||||
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
|
||||
const response = await cache.match(url);
|
||||
/**
|
||||
* The one object URL each cached slide is rendered from, by cache entry.
|
||||
*
|
||||
* An object URL pins its blob in the browser until it is revoked. Every state refresh looks every
|
||||
* cached slide up again, so minting a URL per lookup leaked a blob per slide per refresh, and
|
||||
* within minutes the browser refused the display's own requests for want of resources. So each
|
||||
* entry gets exactly one URL, revoked when the entry is pruned or replaced, and all of them when
|
||||
* the display stops.
|
||||
*/
|
||||
const objectUrls = new Map<string, string>();
|
||||
|
||||
function forget(request: string): void {
|
||||
const url = objectUrls.get(request);
|
||||
if (url !== undefined) {
|
||||
objectUrls.delete(request);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function localUrl(cache: Cache, request: string): Promise<string | undefined> {
|
||||
const existing = objectUrls.get(request);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
const response = await cache.match(request);
|
||||
if (!response) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
// Two lookups of one slide can be in flight at once; the first to finish wins, and the other
|
||||
// gives its URL straight back rather than leaving it pinned.
|
||||
const raced = objectUrls.get(request);
|
||||
if (raced !== undefined) {
|
||||
return raced;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrls.set(request, url);
|
||||
return url;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -220,6 +257,7 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
||||
for (const request of await cache.keys()) {
|
||||
if (!retained.has(request.url)) {
|
||||
await cache.delete(request);
|
||||
forget(request.url);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -227,6 +265,12 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
||||
}
|
||||
},
|
||||
|
||||
release(): void {
|
||||
for (const request of [...objectUrls.keys()]) {
|
||||
forget(request);
|
||||
}
|
||||
},
|
||||
|
||||
async store(key: string, url: string): Promise<string | undefined> {
|
||||
const cache = await open();
|
||||
if (!cache) {
|
||||
@@ -242,6 +286,8 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
||||
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
|
||||
// bytes instead of downloading them again.
|
||||
await cache.put(cacheRequest(key), response.clone());
|
||||
// New bytes under this key: the URL for the old ones is given back before one is made.
|
||||
forget(cacheRequest(key));
|
||||
return localUrl(cache, cacheRequest(key));
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
+125
-39
@@ -1,4 +1,5 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { displayRetryDelayMs } from "../browser/backoff.js";
|
||||
import {
|
||||
type OfflineSnapshot,
|
||||
resolveOffline,
|
||||
@@ -122,6 +123,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
let snapshot: OfflineSnapshot | undefined;
|
||||
let offlineActive = false;
|
||||
let offlineHandle: unknown;
|
||||
/** Retries, each on its own backoff. A display has nobody to press Retry, so none of them give up;
|
||||
* each resets once its request works again. */
|
||||
let sessionHandle: unknown;
|
||||
let sessionAttempt = 0;
|
||||
let refreshAttempt = 0;
|
||||
let realtimeHandle: unknown;
|
||||
let realtimeAttempt = 0;
|
||||
/** Selections this display has tried and failed to render. A broken item is skipped rather than
|
||||
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
|
||||
const unusable = new Set<string>();
|
||||
@@ -497,17 +505,119 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
`channels/${session.channelId}/display-state`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
online = false;
|
||||
await runOffline();
|
||||
await refreshFailed();
|
||||
return;
|
||||
}
|
||||
await apply((await response.json()) as DisplayChannelStateView);
|
||||
const state = (await response.json()) as DisplayChannelStateView;
|
||||
refreshAttempt = 0;
|
||||
await apply(state);
|
||||
} catch {
|
||||
// The server is unreachable. The frame stays and local continuation takes over; nothing about
|
||||
// a network failure belongs on a lobby wall.
|
||||
await refreshFailed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server is unreachable, or answered with an error. The frame stays and local continuation
|
||||
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
|
||||
* again on its own, because a realtime message that would otherwise prompt it may never come.
|
||||
*/
|
||||
async function refreshFailed(): Promise<void> {
|
||||
online = false;
|
||||
await runOffline();
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (refreshHandle !== undefined) {
|
||||
clearTimer(refreshHandle);
|
||||
}
|
||||
refreshHandle = setTimer(() => {
|
||||
refreshHandle = undefined;
|
||||
void refresh();
|
||||
}, displayRetryDelayMs(refreshAttempt));
|
||||
refreshAttempt += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this display, and keeps trying until it can.
|
||||
*
|
||||
* Until it has registered, a display continues from its own persisted state, honestly marked as
|
||||
* offline. The first registration that works brings it live — realtime, heartbeats, and fresh
|
||||
* state — exactly as if it had worked the first time.
|
||||
*/
|
||||
async function register(): Promise<void> {
|
||||
if (stopped || session) {
|
||||
return;
|
||||
}
|
||||
let registered: DisplaySessionView | undefined;
|
||||
try {
|
||||
const response = await options.context.api.request("viewers/session", {
|
||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
// Read, not cast: a registration the display cannot understand is no registration, and is
|
||||
// tried again like any other failure.
|
||||
const parsed = parseViewerSessionResponse(await response.json());
|
||||
registered = parsed.ok ? parsed.value : undefined;
|
||||
}
|
||||
} catch {
|
||||
registered = undefined;
|
||||
}
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (!registered) {
|
||||
online = false;
|
||||
report();
|
||||
sessionHandle = setTimer(() => {
|
||||
sessionHandle = undefined;
|
||||
void register();
|
||||
}, displayRetryDelayMs(sessionAttempt));
|
||||
sessionAttempt += 1;
|
||||
return;
|
||||
}
|
||||
sessionAttempt = 0;
|
||||
session = registered;
|
||||
online = true;
|
||||
subscribe();
|
||||
heartbeatHandle = setInterval(
|
||||
() => void heartbeat(),
|
||||
registered.heartbeatIntervalMs,
|
||||
) as unknown;
|
||||
await refresh();
|
||||
void heartbeat();
|
||||
report();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the display's realtime channel, and again whenever the host reports that a
|
||||
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
|
||||
* is refused outright ends, and until this resubscribes the display would only learn of changes
|
||||
* at its next slot deadline.
|
||||
*/
|
||||
function subscribe(): void {
|
||||
if (stopped || !session) {
|
||||
return;
|
||||
}
|
||||
subscription?.close();
|
||||
subscription = options.context.realtime.subscribe("display-state", {
|
||||
onSnapshot: (state) => onRealtime(state),
|
||||
onStatus: (status) => {
|
||||
online = status === "connected";
|
||||
if (status === "connected") {
|
||||
realtimeAttempt = 0;
|
||||
} else if (status === "closed" && !stopped && realtimeHandle === undefined) {
|
||||
realtimeHandle = setTimer(() => {
|
||||
realtimeHandle = undefined;
|
||||
subscribe();
|
||||
}, displayRetryDelayMs(realtimeAttempt));
|
||||
realtimeAttempt += 1;
|
||||
}
|
||||
report();
|
||||
},
|
||||
onUpdate: (state) => onRealtime(state),
|
||||
});
|
||||
}
|
||||
|
||||
async function heartbeat(): Promise<void> {
|
||||
@@ -578,40 +688,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
}
|
||||
|
||||
if (telemetry) {
|
||||
try {
|
||||
const response = await options.context.api.request("viewers/session", {
|
||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
// Read, not cast: a registration the display cannot understand is no registration.
|
||||
const parsed = parseViewerSessionResponse(await response.json());
|
||||
if (parsed.ok) {
|
||||
session = parsed.value;
|
||||
online = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
online = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
subscription = options.context.realtime.subscribe("display-state", {
|
||||
onSnapshot: (state) => onRealtime(state),
|
||||
onStatus: (status) => {
|
||||
online = status === "connected";
|
||||
report();
|
||||
},
|
||||
onUpdate: (state) => onRealtime(state),
|
||||
});
|
||||
heartbeatHandle = setInterval(
|
||||
() => void heartbeat(),
|
||||
session.heartbeatIntervalMs,
|
||||
) as unknown;
|
||||
await refresh();
|
||||
void heartbeat();
|
||||
await register();
|
||||
}
|
||||
report();
|
||||
},
|
||||
@@ -628,9 +705,18 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
||||
clearTimer(refreshHandle);
|
||||
refreshHandle = undefined;
|
||||
}
|
||||
for (const handle of [sessionHandle, realtimeHandle]) {
|
||||
if (handle !== undefined) {
|
||||
clearTimer(handle);
|
||||
}
|
||||
}
|
||||
sessionHandle = undefined;
|
||||
realtimeHandle = undefined;
|
||||
exitOffline();
|
||||
await stage?.destroy();
|
||||
stage = undefined;
|
||||
// After the stage is gone, so nothing on screen still points at a released URL.
|
||||
media.release();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.1",
|
||||
version: "0.1.2",
|
||||
styles: displayStyles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -127,7 +127,7 @@ export function createSignageModuleRuntime(
|
||||
|
||||
const definition = defineModuleRuntime({
|
||||
id: "signage",
|
||||
version: "0.1.1",
|
||||
version: "0.1.2",
|
||||
|
||||
register({ capabilities, config, http, realtime }) {
|
||||
const settings = readSignageSettings(config);
|
||||
@@ -189,7 +189,7 @@ export function createSignageModuleRuntime(
|
||||
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||
return Response.json({
|
||||
crossfadeMs: current.crossfadeMs,
|
||||
moduleVersion: "0.1.1",
|
||||
moduleVersion: "0.1.2",
|
||||
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||
nativePresentation: current.nativePresentation,
|
||||
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.1",
|
||||
version: "0.1.2",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DISPLAY_RETRY_BASE_MS,
|
||||
DISPLAY_RETRY_MAX_MS,
|
||||
displayRetryDelayMs,
|
||||
} from "../../src/browser/backoff.js";
|
||||
|
||||
describe("display retry backoff", () => {
|
||||
it("doubles from one second and stops at one minute", () => {
|
||||
const longest = (attempt: number) => displayRetryDelayMs(attempt, () => 1);
|
||||
expect([0, 1, 2, 3, 4, 5, 6, 7, 50].map(longest)).toEqual([
|
||||
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("jitters between half and all of each step, and never waits zero", () => {
|
||||
for (const attempt of [0, 3, 6, 40]) {
|
||||
const step = Math.min(DISPLAY_RETRY_MAX_MS, DISPLAY_RETRY_BASE_MS * 2 ** attempt);
|
||||
expect(displayRetryDelayMs(attempt, () => 0)).toBe(step / 2);
|
||||
expect(displayRetryDelayMs(attempt, () => 1)).toBe(step);
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
const delay = displayRetryDelayMs(attempt);
|
||||
expect(delay).toBeGreaterThanOrEqual(step / 2);
|
||||
expect(delay).toBeLessThanOrEqual(step);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a nonsensical attempt or random value as the nearest sensible one", () => {
|
||||
expect(displayRetryDelayMs(-3, () => 1)).toBe(1_000);
|
||||
expect(displayRetryDelayMs(2, () => 7)).toBe(4_000);
|
||||
expect(displayRetryDelayMs(2, () => -1)).toBe(2_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMemoryMediaCache,
|
||||
createMemoryStore,
|
||||
type SignageLocalStore,
|
||||
} from "../../src/browser/storage.js";
|
||||
import {
|
||||
createDisplayController,
|
||||
type DisplayChannelStateView,
|
||||
} from "../../src/display/controller.js";
|
||||
import { createFakeHost, type FakeHost, jsonResponse } from "./support/fake-host.js";
|
||||
|
||||
/**
|
||||
* A display never reaches a dead end.
|
||||
*
|
||||
* Every network path a display depends on — registering, fetching state, realtime — can fail once,
|
||||
* as a kiosk that boots before its network settles or an API that restarts will make it. Each must
|
||||
* be tried again, with backoff, until it works, and the display must then be exactly as live as if
|
||||
* nothing had happened. None of it may need a person to reload a television.
|
||||
*
|
||||
* Time is virtual: every timer the controller schedules goes through the queue below.
|
||||
*/
|
||||
|
||||
const SESSION = {
|
||||
channelId: "chan-1",
|
||||
channelKey: "default",
|
||||
groupId: "group-1",
|
||||
heartbeatIntervalMs: 12_000,
|
||||
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||
viewerName: "Foyer",
|
||||
};
|
||||
|
||||
function selection(id: string) {
|
||||
return {
|
||||
availableUntilMs: null,
|
||||
deadlineAtMs: 5000,
|
||||
durationMs: 5000,
|
||||
id,
|
||||
itemId: `image-${id}`,
|
||||
itemRevision: "rev-1",
|
||||
media: {
|
||||
display: {
|
||||
expiresAtMs: 900_000,
|
||||
height: 1080,
|
||||
url: `https://cdn.invalid/${id}`,
|
||||
width: 1920,
|
||||
},
|
||||
thumbnail: {
|
||||
expiresAtMs: 900_000,
|
||||
height: 270,
|
||||
url: `https://cdn.invalid/${id}-t`,
|
||||
width: 480,
|
||||
},
|
||||
},
|
||||
payload: { kind: "native-image" },
|
||||
presentation: null,
|
||||
providerId: "signage.native-images",
|
||||
renderer: { kind: "native-image" as const },
|
||||
revision: 1,
|
||||
scene: {
|
||||
backgroundColor: "#000000",
|
||||
backgroundImage: false,
|
||||
backgroundMode: "none" as const,
|
||||
blurPixels: 0,
|
||||
darkenOpacity: 0,
|
||||
fit: "fill" as const,
|
||||
shadow: false,
|
||||
},
|
||||
slotId: "slot-1",
|
||||
stale: false,
|
||||
startedAtMs: 0,
|
||||
withdrawn: false,
|
||||
};
|
||||
}
|
||||
|
||||
function channelState(revision = 3): DisplayChannelStateView {
|
||||
const current = selection("sel-1");
|
||||
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: 5000,
|
||||
pendingTimelineId: null,
|
||||
remainingMs: 5000,
|
||||
revision,
|
||||
selectionRevision: 1,
|
||||
startedAtMs: 0,
|
||||
transport: "playing",
|
||||
},
|
||||
selections: [current],
|
||||
serverNowMs: 0,
|
||||
staticHold: null,
|
||||
timeline: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Server behaviour that a test can change mid-run. */
|
||||
interface Server {
|
||||
sessionFailures: number;
|
||||
stateFailing: boolean;
|
||||
sessionRequests: number;
|
||||
stateRequests: number;
|
||||
}
|
||||
|
||||
describe("a display recovers from every failed request without a reload", () => {
|
||||
let clock: number;
|
||||
let timers: Map<number, { at: number; run: () => void }>;
|
||||
let nextTimer: number;
|
||||
let root: HTMLElement;
|
||||
let local: SignageLocalStore;
|
||||
let server: Server;
|
||||
let host: FakeHost;
|
||||
let subscriptions: { onStatus?: (status: string) => void }[];
|
||||
|
||||
beforeEach(() => {
|
||||
clock = 0;
|
||||
timers = new Map();
|
||||
nextTimer = 1;
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
local = createMemoryStore();
|
||||
server = { sessionFailures: 0, sessionRequests: 0, stateFailing: false, stateRequests: 0 };
|
||||
subscriptions = [];
|
||||
host = createFakeHost({
|
||||
permissions: ["signage.display.view"],
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "viewers/session",
|
||||
respond: () => {
|
||||
server.sessionRequests += 1;
|
||||
if (server.sessionFailures > 0) {
|
||||
server.sessionFailures -= 1;
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return jsonResponse(SESSION);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "channels/chan-1/display-state",
|
||||
respond: () => {
|
||||
server.stateRequests += 1;
|
||||
if (server.stateFailing) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return jsonResponse(channelState());
|
||||
},
|
||||
},
|
||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||
],
|
||||
});
|
||||
Object.assign(host.context, {
|
||||
realtime: {
|
||||
subscribe: (_channel: string, handlers: { onStatus?: (status: string) => void }) => {
|
||||
subscriptions.push(handlers);
|
||||
return { close: () => undefined };
|
||||
},
|
||||
} satisfies Pick<ModuleUiHostContext["realtime"], "subscribe"> as never,
|
||||
});
|
||||
});
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves virtual time forward, firing every timer that falls due, in order. */
|
||||
async function advance(ms: number): Promise<void> {
|
||||
const end = clock + ms;
|
||||
for (;;) {
|
||||
const due = [...timers]
|
||||
.filter(([, timer]) => timer.at <= end)
|
||||
.sort((left, right) => left[1].at - right[1].at)[0];
|
||||
if (!due) {
|
||||
break;
|
||||
}
|
||||
timers.delete(due[0]);
|
||||
clock = due[1].at;
|
||||
due[1].run();
|
||||
await settle();
|
||||
}
|
||||
clock = end;
|
||||
}
|
||||
|
||||
function build() {
|
||||
return createDisplayController({
|
||||
clearTimer: (handle) => timers.delete(handle as number),
|
||||
context: host.context,
|
||||
decodeImage: () => Promise.resolve(),
|
||||
media: createMemoryMediaCache(),
|
||||
newViewerKey: () => "viewer-fixed-key-0001",
|
||||
now: () => clock,
|
||||
root,
|
||||
setTimer: (run, delayMs) => {
|
||||
const id = nextTimer;
|
||||
nextTimer += 1;
|
||||
timers.set(id, { at: clock + delayMs, run });
|
||||
return id;
|
||||
},
|
||||
store: local,
|
||||
});
|
||||
}
|
||||
|
||||
/** One ordinary live run, so the display has a persisted snapshot to continue from, exactly as a
|
||||
* screen that has been on the wall for a while does. */
|
||||
async function runOnceLive(): Promise<void> {
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
expect(root.dataset.offline).toBe("false");
|
||||
await display.stop();
|
||||
timers.clear();
|
||||
}
|
||||
|
||||
it("registers once the server answers, after its first registration failed", async () => {
|
||||
await runOnceLive();
|
||||
server.sessionFailures = 1;
|
||||
const sessionsBefore = server.sessionRequests;
|
||||
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
// Continuing from its own state while it cannot register: the honest banner.
|
||||
expect(root.dataset.offline).toBe("true");
|
||||
|
||||
await advance(60_000);
|
||||
|
||||
expect(server.sessionRequests - sessionsBefore).toBeGreaterThanOrEqual(2);
|
||||
expect(root.dataset.offline).toBe("false");
|
||||
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("keeps retrying registration with backoff, and never gives up", async () => {
|
||||
await runOnceLive();
|
||||
server.sessionFailures = 8;
|
||||
const sessionsBefore = server.sessionRequests;
|
||||
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
// Eight failures back off for at most 1 + 2 + 4 + 8 + 16 + 32 + 60 + 60 seconds, the last two at
|
||||
// the one-minute ceiling. Five virtual minutes is enough for all of them and the success after.
|
||||
await advance(5 * 60_000);
|
||||
|
||||
expect(server.sessionRequests - sessionsBefore).toBe(9);
|
||||
expect(root.dataset.offline).toBe("false");
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("retries a failed state refresh until the server answers, with no realtime nudge", async () => {
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
expect(root.dataset.offline).toBe("false");
|
||||
|
||||
// The slot's deadline refresh fails, as it would mid-restart. Realtime says nothing.
|
||||
server.stateFailing = true;
|
||||
await advance(6_000);
|
||||
expect(root.dataset.offline).toBe("true");
|
||||
const failedRequests = server.stateRequests;
|
||||
|
||||
server.stateFailing = false;
|
||||
await advance(120_000);
|
||||
|
||||
expect(server.stateRequests).toBeGreaterThan(failedRequests);
|
||||
expect(root.dataset.offline).toBe("false");
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("subscribes again when the host reports its realtime stream closed for good", async () => {
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
expect(subscriptions).toHaveLength(1);
|
||||
|
||||
subscriptions[0]?.onStatus?.("closed");
|
||||
await settle();
|
||||
await advance(60_000);
|
||||
|
||||
expect(subscriptions.length).toBeGreaterThanOrEqual(2);
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("schedules nothing after it is stopped", async () => {
|
||||
await runOnceLive();
|
||||
server.sessionFailures = 3;
|
||||
const display = build();
|
||||
await display.start();
|
||||
await settle();
|
||||
await display.stop();
|
||||
const sessionsAtStop = server.sessionRequests;
|
||||
|
||||
await advance(5 * 60_000);
|
||||
|
||||
expect(server.sessionRequests).toBe(sessionsAtStop);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createBrowserMediaCache, type SignageMediaCache } from "../../src/browser/storage.js";
|
||||
|
||||
/**
|
||||
* The Cache Storage media cache hands out object URLs, and every live one pins its blob in the
|
||||
* browser until it is revoked.
|
||||
*
|
||||
* Every state refresh looks up every cached slide, so a cache that minted a fresh URL per lookup
|
||||
* leaked one blob per slide per refresh. In a real browser that exhausted blob storage within
|
||||
* minutes: the display's own state requests then failed with `net::ERR_INSUFFICIENT_RESOURCES` and
|
||||
* it fell back to its offline banner on a perfectly good network.
|
||||
*/
|
||||
|
||||
/** Just enough Cache Storage: one named cache, keyed by request URL. */
|
||||
function fakeCacheStorage() {
|
||||
const entries = new Map<string, { body: ArrayBuffer; type: string }>();
|
||||
const cache = {
|
||||
async delete(request: Request | string): Promise<boolean> {
|
||||
return entries.delete(typeof request === "string" ? request : request.url);
|
||||
},
|
||||
async keys(): Promise<Request[]> {
|
||||
return [...entries.keys()].map((url) => new Request(url));
|
||||
},
|
||||
async match(request: Request | string): Promise<Response | undefined> {
|
||||
const entry = entries.get(typeof request === "string" ? request : request.url);
|
||||
return entry
|
||||
? new Response(entry.body, { headers: { "content-type": entry.type } })
|
||||
: undefined;
|
||||
},
|
||||
async put(request: Request | string, response: Response): Promise<void> {
|
||||
entries.set(typeof request === "string" ? request : request.url, {
|
||||
body: await response.arrayBuffer(),
|
||||
type: response.headers.get("content-type") ?? "application/octet-stream",
|
||||
});
|
||||
},
|
||||
};
|
||||
return { open: async () => cache } as unknown as CacheStorage;
|
||||
}
|
||||
|
||||
describe("Signage's media cache and object URLs", () => {
|
||||
const realCreate = URL.createObjectURL;
|
||||
const realRevoke = URL.revokeObjectURL;
|
||||
let live: Set<string>;
|
||||
let media: SignageMediaCache;
|
||||
|
||||
beforeEach(() => {
|
||||
live = new Set();
|
||||
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({
|
||||
caches: fakeCacheStorage(),
|
||||
fetch: async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }),
|
||||
location: { origin: "https://api.example.test" },
|
||||
} as unknown as typeof globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = realCreate;
|
||||
URL.revokeObjectURL = realRevoke;
|
||||
});
|
||||
|
||||
const keys = ["slide/a", "slide/b", "slide/c"];
|
||||
|
||||
it("keeps one live object URL per cached slide, however often it is looked up", async () => {
|
||||
for (const key of keys) {
|
||||
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||
}
|
||||
// A state refresh looks every slide up again; a long-running display does this for months.
|
||||
for (let refresh = 0; refresh < 100; refresh += 1) {
|
||||
for (const key of keys) {
|
||||
await media.find(key);
|
||||
}
|
||||
}
|
||||
expect(live.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("hands out the same URL for the same bytes", async () => {
|
||||
await media.store("slide/a", "https://files.example.test/a?sig=1");
|
||||
const first = await media.find("slide/a");
|
||||
const second = await media.find("slide/a");
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("revokes a pruned slide's URL", async () => {
|
||||
for (const key of keys) {
|
||||
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||
await media.find(key);
|
||||
}
|
||||
await media.prune(["slide/a"]);
|
||||
expect(live.size).toBe(1);
|
||||
expect(await media.find("slide/b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes the URL it replaces when a slide is stored again", async () => {
|
||||
await media.store("slide/a", "https://files.example.test/a?sig=1");
|
||||
await media.store("slide/a", "https://files.example.test/a?sig=2");
|
||||
await media.find("slide/a");
|
||||
expect(live.size).toBe(1);
|
||||
});
|
||||
|
||||
it("releases every URL it handed out when the display stops", async () => {
|
||||
for (const key of keys) {
|
||||
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||
await media.find(key);
|
||||
}
|
||||
media.release();
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -127,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
|
||||
/** The settings payload every screen loads first. */
|
||||
export const FAKE_SETTINGS = Object.freeze({
|
||||
crossfadeMs: 1000,
|
||||
moduleVersion: "0.1.1",
|
||||
moduleVersion: "0.1.2",
|
||||
nativeImageDurationMs: 7000,
|
||||
nativePresentation: {
|
||||
backgroundColor: "#000000",
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
|
||||
expect(manifest.manifestVersion).toBe(7);
|
||||
expect(manifest.id).toBe("signage");
|
||||
expect(manifest.name).toBe("Signage");
|
||||
expect(manifest.version).toBe("0.1.1");
|
||||
expect(manifest.version).toBe("0.1.2");
|
||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
|
||||
|
||||
describe("Signage server runtime", () => {
|
||||
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
|
||||
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" });
|
||||
});
|
||||
|
||||
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
crossfadeMs: 400,
|
||||
moduleVersion: "0.1.1",
|
||||
moduleVersion: "0.1.2",
|
||||
nativeImageDurationMs: 12_000,
|
||||
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
||||
rendererInputVersion: "1.0.0",
|
||||
|
||||
Reference in New Issue
Block a user