Compare commits
2
Commits
v0.1.2
..
776540ff78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776540ff78 | ||
|
|
7483d7cbbe |
@@ -1,217 +0,0 @@
|
|||||||
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,4 +4,3 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
build/
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
|
Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
|
||||||
0.1.1. What exists today:
|
|
||||||
|
|
||||||
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
||||||
one logical storage bucket.
|
one logical storage bucket.
|
||||||
@@ -35,10 +34,10 @@ Version 0.1.2 is feature-complete for its declared scope, and Signage has run in
|
|||||||
renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate
|
renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate
|
||||||
blackout, and offline continuation from this display's own persisted state.
|
blackout, and offline continuation from this display's own persisted state.
|
||||||
|
|
||||||
No cutover from the legacy Announcements container has happened or is planned by this repository.
|
Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is
|
||||||
Video, audio, MQTT, Home Assistant, Companion, and a notification outbox are out of scope; so is
|
planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox
|
||||||
any content module — Signage ships with no announcements, scripture, or trivia, and cannot render
|
are out of scope; so is any content module — Signage ships with no announcements, scripture, or
|
||||||
any until one is installed.
|
trivia, and cannot render any until one is installed.
|
||||||
|
|
||||||
### Offline cold start is not yet possible
|
### Offline cold start is not yet possible
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# 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
@@ -1,21 +0,0 @@
|
|||||||
#!/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",
|
"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.2",
|
"version": "0.1.1",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/signage",
|
"name": "@ekklesia/signage",
|
||||||
"version": "0.1.2",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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())));
|
|
||||||
}
|
|
||||||
+3
-49
@@ -36,9 +36,6 @@ export interface SignageMediaCache {
|
|||||||
* can never leave a screen with less than it had. */
|
* can never leave a screen with less than it had. */
|
||||||
prune(keep: readonly string[]): Promise<void>;
|
prune(keep: readonly string[]): Promise<void>;
|
||||||
count(): Promise<number>;
|
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
|
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never
|
||||||
@@ -89,9 +86,6 @@ export function createMemoryMediaCache(): SignageMediaCache {
|
|||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
release(): void {
|
|
||||||
// Holds plain URLs, nothing the browser has to be given back.
|
|
||||||
},
|
|
||||||
store(key: string, url: string): Promise<string | undefined> {
|
store(key: string, url: string): Promise<string | undefined> {
|
||||||
stored.set(key, url);
|
stored.set(key, url);
|
||||||
return Promise.resolve(url);
|
return Promise.resolve(url);
|
||||||
@@ -185,45 +179,14 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
|||||||
return `${origin}/${key}`;
|
return `${origin}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
|
||||||
* The one object URL each cached slide is rendered from, by cache entry.
|
const response = await cache.match(url);
|
||||||
*
|
|
||||||
* 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) {
|
if (!response) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
// Two lookups of one slide can be in flight at once; the first to finish wins, and the other
|
return URL.createObjectURL(blob);
|
||||||
// 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 {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -257,7 +220,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
|||||||
for (const request of await cache.keys()) {
|
for (const request of await cache.keys()) {
|
||||||
if (!retained.has(request.url)) {
|
if (!retained.has(request.url)) {
|
||||||
await cache.delete(request);
|
await cache.delete(request);
|
||||||
forget(request.url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -265,12 +227,6 @@ 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> {
|
async store(key: string, url: string): Promise<string | undefined> {
|
||||||
const cache = await open();
|
const cache = await open();
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
@@ -286,8 +242,6 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
|
|||||||
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
|
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
|
||||||
// bytes instead of downloading them again.
|
// bytes instead of downloading them again.
|
||||||
await cache.put(cacheRequest(key), response.clone());
|
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));
|
return localUrl(cache, cacheRequest(key));
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+38
-124
@@ -1,5 +1,4 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { displayRetryDelayMs } from "../browser/backoff.js";
|
|
||||||
import {
|
import {
|
||||||
type OfflineSnapshot,
|
type OfflineSnapshot,
|
||||||
resolveOffline,
|
resolveOffline,
|
||||||
@@ -123,13 +122,6 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
let snapshot: OfflineSnapshot | undefined;
|
let snapshot: OfflineSnapshot | undefined;
|
||||||
let offlineActive = false;
|
let offlineActive = false;
|
||||||
let offlineHandle: unknown;
|
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
|
/** 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. */
|
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
|
||||||
const unusable = new Set<string>();
|
const unusable = new Set<string>();
|
||||||
@@ -505,119 +497,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
`channels/${session.channelId}/display-state`,
|
`channels/${session.channelId}/display-state`,
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await refreshFailed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const state = (await response.json()) as DisplayChannelStateView;
|
|
||||||
refreshAttempt = 0;
|
|
||||||
await apply(state);
|
|
||||||
} catch {
|
|
||||||
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;
|
online = false;
|
||||||
await runOffline();
|
await runOffline();
|
||||||
if (stopped) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (refreshHandle !== undefined) {
|
await apply((await response.json()) as DisplayChannelStateView);
|
||||||
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 {
|
} catch {
|
||||||
registered = undefined;
|
// The server is unreachable. The frame stays and local continuation takes over; nothing about
|
||||||
}
|
// a network failure belongs on a lobby wall.
|
||||||
if (stopped) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!registered) {
|
|
||||||
online = false;
|
online = false;
|
||||||
report();
|
await runOffline();
|
||||||
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> {
|
async function heartbeat(): Promise<void> {
|
||||||
@@ -688,7 +578,40 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (telemetry) {
|
if (telemetry) {
|
||||||
await register();
|
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();
|
||||||
}
|
}
|
||||||
report();
|
report();
|
||||||
},
|
},
|
||||||
@@ -705,18 +628,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
clearTimer(refreshHandle);
|
clearTimer(refreshHandle);
|
||||||
refreshHandle = undefined;
|
refreshHandle = undefined;
|
||||||
}
|
}
|
||||||
for (const handle of [sessionHandle, realtimeHandle]) {
|
|
||||||
if (handle !== undefined) {
|
|
||||||
clearTimer(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionHandle = undefined;
|
|
||||||
realtimeHandle = undefined;
|
|
||||||
exitOffline();
|
exitOffline();
|
||||||
await stage?.destroy();
|
await stage?.destroy();
|
||||||
stage = undefined;
|
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({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.2",
|
version: "0.1.1",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function createSignageModuleRuntime(
|
|||||||
|
|
||||||
const definition = defineModuleRuntime({
|
const definition = defineModuleRuntime({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.2",
|
version: "0.1.1",
|
||||||
|
|
||||||
register({ capabilities, config, http, realtime }) {
|
register({ capabilities, config, http, realtime }) {
|
||||||
const settings = readSignageSettings(config);
|
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.
|
// 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.2",
|
moduleVersion: "0.1.1",
|
||||||
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.2",
|
version: "0.1.1",
|
||||||
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);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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. */
|
/** 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.2",
|
moduleVersion: "0.1.1",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
|
|||||||
expect(manifest.manifestVersion).toBe(7);
|
expect(manifest.manifestVersion).toBe(7);
|
||||||
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.2");
|
expect(manifest.version).toBe("0.1.1");
|
||||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +142,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 () => {
|
||||||
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" });
|
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -241,7 +241,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.2",
|
moduleVersion: "0.1.1",
|
||||||
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