Compare commits
11
Commits
bf4e98fa30
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ae907f59 | ||
|
|
855b5f9f54 | ||
|
|
e6c6553afc | ||
|
|
525335e6dd | ||
|
|
e3c5e546eb | ||
|
|
607f375809 | ||
|
|
1c6c9ccc1f | ||
|
|
ab4142fb95 | ||
|
|
6869ae433a | ||
|
|
cbf2a8ee39 | ||
|
|
cfa4628f8c |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Checks that Signage's CI cleans up every stack it starts, even when the runner kills a job (the
|
||||||
|
# platform's rule: docs/development/security.md, "Disposable stacks never outlive their job").
|
||||||
|
#
|
||||||
|
# bash ci/check-cleanup.sh
|
||||||
|
#
|
||||||
|
# The `integration` job is the one that starts Docker stacks. It must have its own
|
||||||
|
# EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final
|
||||||
|
# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
workflow="$root/.gitea/workflows/ci.yml"
|
||||||
|
failures=0
|
||||||
|
check() {
|
||||||
|
if eval "$1"; then
|
||||||
|
echo "ok $2"
|
||||||
|
else
|
||||||
|
echo "FAIL $2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The integration job's lines, from its key to the next job (or the end of the file).
|
||||||
|
job="$(awk '/^ integration:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||||
|
line_of() { grep -n -F -- "$1" <<<"$job" | head -1 | cut -d: -f1 || true; }
|
||||||
|
|
||||||
|
stale="$(line_of 'ci-sweep.sh --stale')"
|
||||||
|
run="$(line_of 'bash ci/integration.sh')"
|
||||||
|
own="$(line_of 'ci-sweep.sh --job')"
|
||||||
|
last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)"
|
||||||
|
|
||||||
|
check '[[ -n "$job" ]]' "the workflow has an integration job"
|
||||||
|
check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key"
|
||||||
|
check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything"
|
||||||
|
check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \
|
||||||
|
"its last step removes its own stacks"
|
||||||
|
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
|
||||||
|
check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \
|
||||||
|
"the image ci/integration.sh builds carries the CI labels"
|
||||||
|
|
||||||
|
((failures == 0)) || {
|
||||||
|
echo "check-cleanup: ${failures} check(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
two-pages
|
two-pages
|
||||||
session-blip
|
session-blip
|
||||||
stream-failure
|
stream-failure
|
||||||
|
stream-recycle
|
||||||
|
|||||||
+7
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createBrowserMediaCache, type SignageMediaCache } from "../../src/browser/storage.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Cache Storage media cache hands out object URLs, and every live one pins its blob in the
|
||||||
|
* browser until it is revoked.
|
||||||
|
*
|
||||||
|
* Every state refresh looks up every cached slide, so a cache that minted a fresh URL per lookup
|
||||||
|
* leaked one blob per slide per refresh. In a real browser that exhausted blob storage within
|
||||||
|
* minutes: the display's own state requests then failed with `net::ERR_INSUFFICIENT_RESOURCES` and
|
||||||
|
* it fell back to its offline banner on a perfectly good network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Just enough Cache Storage: one named cache, keyed by request URL. */
|
||||||
|
function fakeCacheStorage() {
|
||||||
|
const entries = new Map<string, { body: ArrayBuffer; type: string }>();
|
||||||
|
const cache = {
|
||||||
|
async delete(request: Request | string): Promise<boolean> {
|
||||||
|
return entries.delete(typeof request === "string" ? request : request.url);
|
||||||
|
},
|
||||||
|
async keys(): Promise<Request[]> {
|
||||||
|
return [...entries.keys()].map((url) => new Request(url));
|
||||||
|
},
|
||||||
|
async match(request: Request | string): Promise<Response | undefined> {
|
||||||
|
const entry = entries.get(typeof request === "string" ? request : request.url);
|
||||||
|
return entry
|
||||||
|
? new Response(entry.body, { headers: { "content-type": entry.type } })
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
async put(request: Request | string, response: Response): Promise<void> {
|
||||||
|
entries.set(typeof request === "string" ? request : request.url, {
|
||||||
|
body: await response.arrayBuffer(),
|
||||||
|
type: response.headers.get("content-type") ?? "application/octet-stream",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { open: async () => cache } as unknown as CacheStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage's media cache and object URLs", () => {
|
||||||
|
const realCreate = URL.createObjectURL;
|
||||||
|
const realRevoke = URL.revokeObjectURL;
|
||||||
|
let live: Set<string>;
|
||||||
|
let media: SignageMediaCache;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
live = new Set();
|
||||||
|
let next = 0;
|
||||||
|
URL.createObjectURL = () => {
|
||||||
|
next += 1;
|
||||||
|
const url = `blob:test/${next}`;
|
||||||
|
live.add(url);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
URL.revokeObjectURL = (url: string) => {
|
||||||
|
live.delete(url);
|
||||||
|
};
|
||||||
|
media = createBrowserMediaCache({
|
||||||
|
caches: fakeCacheStorage(),
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }),
|
||||||
|
location: { origin: "https://api.example.test" },
|
||||||
|
} as unknown as typeof globalThis);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
URL.createObjectURL = realCreate;
|
||||||
|
URL.revokeObjectURL = realRevoke;
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = ["slide/a", "slide/b", "slide/c"];
|
||||||
|
|
||||||
|
it("keeps one live object URL per cached slide, however often it is looked up", async () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||||
|
}
|
||||||
|
// A state refresh looks every slide up again; a long-running display does this for months.
|
||||||
|
for (let refresh = 0; refresh < 100; refresh += 1) {
|
||||||
|
for (const key of keys) {
|
||||||
|
await media.find(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(live.size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands out the same URL for the same bytes", async () => {
|
||||||
|
await media.store("slide/a", "https://files.example.test/a?sig=1");
|
||||||
|
const first = await media.find("slide/a");
|
||||||
|
const second = await media.find("slide/a");
|
||||||
|
expect(first).toBeDefined();
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes a pruned slide's URL", async () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||||
|
await media.find(key);
|
||||||
|
}
|
||||||
|
await media.prune(["slide/a"]);
|
||||||
|
expect(live.size).toBe(1);
|
||||||
|
expect(await media.find("slide/b")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes the URL it replaces when a slide is stored again", async () => {
|
||||||
|
await media.store("slide/a", "https://files.example.test/a?sig=1");
|
||||||
|
await media.store("slide/a", "https://files.example.test/a?sig=2");
|
||||||
|
await media.find("slide/a");
|
||||||
|
expect(live.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases every URL it handed out when the display stops", async () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
await media.store(key, `https://files.example.test/${key}?sig=1`);
|
||||||
|
await media.find(key);
|
||||||
|
}
|
||||||
|
media.release();
|
||||||
|
expect(live.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -127,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
|
|||||||
/** The settings payload every screen loads first. */
|
/** 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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user