11 Commits
Author SHA1 Message Date
The_River 86ae907f59 Merge pull request 'chore: release signage 0.1.2' (#9) from chore/release-0.1.2 into main
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 14s
CI / test (push) Successful in 20s
CI / build (push) Successful in 19s
CI / integration (push) Successful in 5m41s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/9
Reviewed-by: The_River <[email protected]>
2026-10-06 03:51:14 +00:00
ekklesiaandClaude Opus 5.5 855b5f9f54 chore: release signage 0.1.2
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 19s
CI / integration (pull_request) Successful in 6m0s
0.1.2 fixes what the first production Signage deployment found:
- #4: the display and server share one message schema; every heartbeat
  from an image slide used to be refused with a 400;
- #5: every display request retries with backoff and never gives up, so
  a display that failed one request no longer stays offline until
  reloaded;
- #6: one object URL per cached slide, released when done; the leak
  exhausted the browser within minutes;
- CI (#7, #8): every change runs inside a pinned Ekklesia, end to end
  in a real browser, and cleans up even when killed.

compatibility.platform stays ^0.1.0: every fix is module-side, and CI
tested it against platform v0.1.1 plus test-only scripts. The README no
longer says Signage is unreleased or undeployed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 22:08:31 -05:00
The_River e6c6553afc Merge pull request 'ci: the integration job removes its stacks even when the runner kills it' (#8) from ci/stack-cleanup into main
CI / lint (push) Successful in 14s
CI / typecheck (push) Successful in 15s
CI / test (push) Successful in 22s
CI / build (push) Successful in 21s
CI / integration (push) Successful in 5m43s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/8
Reviewed-by: The_River <[email protected]>
2026-10-06 02:58:11 +00:00
The_River 525335e6dd Merge pull request 'fix(display): keep one object URL per cached slide, and give them back' (#6) from fix/display-media-object-urls into main
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 14s
CI / test (push) Successful in 21s
CI / build (push) Successful in 18s
CI / integration (push) Canceled after 1m3s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/6
Reviewed-by: The_River <[email protected]>
2026-10-06 02:56:01 +00:00
ekklesiaandClaude Opus 5.5 e3c5e546eb ci: run stream-recycle on every change
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 20s
CI / build (pull_request) Successful in 19s
CI / integration (pull_request) Successful in 5m41s
It failed on main before this PR: the media cache leaked one blob per
slide per refresh until the browser refused the display's own requests.
With one object URL per slide it holds three live URLs for three slides,
and CI now keeps it there, together with "stays live for the whole watch".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:45:28 -05:00
ekklesiaandClaude Opus 5.5 607f375809 fix(display): keep one object URL per cached slide, and give them back
The Cache Storage media cache minted a new object URL on every lookup
and never revoked any. Every state refresh looks up every cached slide,
so a running display pinned one more blob per slide per refresh until
the browser refused its requests with net::ERR_INSUFFICIENT_RESOURCES.
It then showed its offline banner on a working network. Two tabs leaked
twice as fast, and real photographs fill blob storage far sooner than
test slides.

Each cache entry now has exactly one object URL:
- every lookup reuses it;
- pruning or replacing the entry revokes it;
- release(), new on SignageMediaCache, revokes all of them, and the
  controller calls it on stop once the stage is gone.

Two lookups of one slide racing each other keep the first URL and make
no second one. The in-memory cache has nothing to give back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:45:19 -05:00
ekklesiaandClaude Opus 5.5 1c6c9ccc1f test(display): one live object URL per cached slide, released when done
The media cache minted a new object URL on every lookup and never
revoked one. Each state refresh looks up every cached slide, so a
display leaked one pinned blob per slide per refresh. In a real browser
(Ekklesia draft PR #56, scenario stream-recycle) its own state requests
then failed with net::ERR_INSUFFICIENT_RESOURCES within about four
minutes, and it showed its offline banner on a working network.

Fails today: 303 live URLs for 3 slides after 100 refreshes. The same
bytes get a new URL each time, nothing is revoked on prune or replace,
and there is no release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:45:19 -05:00
The_River ab4142fb95 Merge pull request 'fix(display): retry every failed request with backoff, and never give up' (#5) from fix/display-recovery into main
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 14s
CI / test (push) Successful in 20s
CI / build (push) Successful in 18s
CI / integration (push) Successful in 3m35s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/5
Reviewed-by: The_River <[email protected]>
2026-10-06 02:44:20 +00:00
ekklesiaandClaude Opus 5.5 6869ae433a ci: pin the platform at the merged sweep, 874deab on main
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 14s
CI / test (pull_request) Successful in 19s
CI / build (pull_request) Successful in 18s
CI / integration (pull_request) Successful in 3m49s
Ekklesia #60 was rebase-merged, so the PR head 00e5441 never became part
of main, and ci/assemble.sh rightly refuses it. 874deab is #60's merge on
main. ci-sweep.sh, ci-project.sh and smoke-deploy.sh are byte-identical
to the old pin, no package file changed, and the assembled lockfile
still passes a frozen install.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:19:57 -05:00
ekklesiaandClaude Opus 5.5 cbf2a8ee39 ci: the integration job removes its stacks even when the runner kills it
CI / typecheck (pull_request) Failing after 3s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Failing after 4s
CI / integration (pull_request) Skipped
CI / lint (pull_request) Failing after 3s
The platform's rule (its ci-sweep.sh, docs/development/security.md),
applied to the one Signage job that starts Docker stacks:
- it has its own EKKLESIA_CI_JOB_KEY;
- it sweeps stale CI stacks from the runner before it starts;
- it removes its own stacks in a final `if: always()` step, which
  tolerates a failed assembly, since nothing was started then;
- the image ci/integration.sh builds is named and labeled as a CI stack
  of this run and job, with the time CI built it, so the sweep finds it.

ci/platform.env moves to the platform commit that adds the sweep
(Ekklesia #60). The assembled lockfile is unchanged, and still passes a
frozen install.

Verified: SIGKILL of the integration script's whole process group
mid-run left 11 resources (2 containers, 4 networks, 4 volumes and the
image). `ci-sweep.sh --job` removed exactly those 11. A normal run
passes, and the job sweep after it finds nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:05:15 -05:00
ekklesiaandClaude Opus 5.5 cfa4628f8c test(ci): the integration job must clean up its stacks even when killed
The platform leaked a whole CI stack for two days: a job the runner
kills never runs its cleanup trap. The platform's rule now applies here:
the job that starts Docker stacks (integration) has its own
EKKLESIA_CI_JOB_KEY, sweeps stale CI stacks first, removes its own in a
final if: always() step, and labels the image it builds.

ci/check-cleanup.sh checks that, and the lint job runs it. It fails
today on all five counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 20:58:04 -05:00
16 changed files with 260 additions and 20 deletions
+20
View File
@@ -61,6 +61,9 @@ jobs:
working-directory: build/workspace working-directory: build/workspace
run: pnpm --filter @ekklesia/signage lint run: pnpm --filter @ekklesia/signage lint
- name: Check that CI cleans up every stack it starts
run: bash ci/check-cleanup.sh
typecheck: typecheck:
name: typecheck name: typecheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -184,6 +187,9 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
env: env:
DOCKER_BUILDKIT: "1" 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: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -193,5 +199,19 @@ jobs:
- name: Assemble Signage into the pinned platform - name: Assemble Signage into the pinned platform
run: bash ci/assemble.sh 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 - name: Build the image and run the display scenarios
run: bash ci/integration.sh 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
+6 -5
View File
@@ -16,7 +16,8 @@ Ekklesia and vendors none of its SDKs.
## Status ## 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 - The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
one logical storage bucket. 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 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.
Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is No cutover from the legacy Announcements container has happened or is planned by this repository.
planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox Video, audio, MQTT, Home Assistant, Companion, and a notification outbox are out of scope; so is
are out of scope; so is any content module — Signage ships with no announcements, scripture, or any content module — Signage ships with no announcements, scripture, or trivia, and cannot render
trivia, and cannot render any until one is installed. any until one is installed.
### Offline cold start is not yet possible ### Offline cold start is not yet possible
+45
View File
@@ -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
}
+1
View File
@@ -4,3 +4,4 @@
two-pages two-pages
session-blip session-blip
stream-failure stream-failure
stream-recycle
+7 -1
View File
@@ -15,7 +15,12 @@ workspace="$root/build/workspace"
exit 1 exit 1
} }
image="signage-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-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 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' ' ')" scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')"
@@ -25,6 +30,7 @@ scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios
} }
DOCKER_BUILDKIT=1 docker build --quiet --file "$workspace/deploy/Dockerfile" --tag "$image" \ 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 "$workspace" >/dev/null
echo "integration: built the deployment image with Signage; scenarios: ${scenarios}" echo "integration: built the deployment image with Signage; scenarios: ${scenarios}"
+1 -1
View File
@@ -3,4 +3,4 @@
# #
# Move it deliberately: change the commit, run `bash ci/update-lock.sh`, and commit both. # 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_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
EKKLESIA_PLATFORM_COMMIT=f50e8f42018c15698709739e2ba54771e97df08a EKKLESIA_PLATFORM_COMMIT=874deabea3468eb5c3e907f0821718b077a682c7
+1 -1
View File
@@ -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.1", "version": "0.1.2",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.1.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/signage", "name": "@ekklesia/signage",
"version": "0.1.1", "version": "0.1.2",
"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.",
+49 -3
View File
@@ -36,6 +36,9 @@ 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
@@ -86,6 +89,9 @@ 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);
@@ -179,14 +185,45 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
return `${origin}/${key}`; 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) { if (!response) {
return undefined; return undefined;
} }
try { try {
const blob = await response.blob(); 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 { } catch {
return undefined; return undefined;
} }
@@ -220,6 +257,7 @@ 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 {
@@ -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> { async store(key: string, url: string): Promise<string | undefined> {
const cache = await open(); const cache = await open();
if (!cache) { 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 // 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;
+2
View File
@@ -715,6 +715,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
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();
}, },
}; };
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.1", version: "0.1.2",
styles: displayStyles, styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+2 -2
View File
@@ -127,7 +127,7 @@ export function createSignageModuleRuntime(
const definition = defineModuleRuntime({ const definition = defineModuleRuntime({
id: "signage", id: "signage",
version: "0.1.1", version: "0.1.2",
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.1", moduleVersion: "0.1.2",
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
View File
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.1", version: "0.1.2",
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);
+119
View File
@@ -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);
});
});
+1 -1
View File
@@ -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.1", moduleVersion: "0.1.2",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
+3 -3
View File
@@ -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.1"); expect(manifest.version).toBe("0.1.2");
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.1" }); expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" });
}); });
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.1", moduleVersion: "0.1.2",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",