fix(display): keep a live display across reconnects, and return a revoked one to pairing
Every browser 'online' event reset the display host to its loading state and mounted the module again: a fresh viewer session, a fresh stage, and every cached object URL released, on each Wi-Fi blip. And a device revoked while its display was open never noticed; it kept playing cached content and polling 403s until someone reloaded the page. The host now re-resolves its descriptor in the background (ADR 0043) and only replaces the frame when the answer actually changed: a new version or principal remounts, a refusal returns the screen to pairing (ADR 0036), and any other failure keeps the running display. A module request refused with 401/403 prompts the same revalidation, throttled, so revocation is noticed within one heartbeat. The offline indicator waits ten seconds before appearing, so a momentary blip shows nothing, and no longer names the platform on a lobby wall. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06bad2fe36
commit
4bcdb578e6
@@ -1,6 +1,6 @@
|
|||||||
import { designToken } from "@ekklesia/design-tokens";
|
import { designToken } from "@ekklesia/design-tokens";
|
||||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||||
import { type ReactElement, useEffect, useState } from "react";
|
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
type DisplayHostBootstrap,
|
type DisplayHostBootstrap,
|
||||||
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
|
|||||||
|
|
||||||
type DisplayState =
|
type DisplayState =
|
||||||
| { readonly status: "loading"; readonly generation: number }
|
| { readonly status: "loading"; readonly generation: number }
|
||||||
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
|
| {
|
||||||
|
readonly status: "ready";
|
||||||
|
readonly bootstrap: DisplayHostBootstrap & { status: "ready" };
|
||||||
|
/** Changes only when the mounted display must be replaced, never merely because the network
|
||||||
|
* came back. It keys the frame, so it is the one thing that can remount a running display. */
|
||||||
|
readonly generation: number;
|
||||||
|
}
|
||||||
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
||||||
| { readonly status: "pairing" }
|
| { readonly status: "pairing" }
|
||||||
| { readonly status: "unavailable" }
|
| { readonly status: "unavailable" }
|
||||||
@@ -31,6 +37,54 @@ export interface DisplayAppProps {
|
|||||||
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
|
/** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point
|
||||||
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
|
* supplies the real ones; omitting it leaves a display that simply never reports being offline. */
|
||||||
readonly connectivity?: DisplayConnectivitySource;
|
readonly connectivity?: DisplayConnectivitySource;
|
||||||
|
/** How long the display must stay offline before the corner indicator appears. Test seam only. */
|
||||||
|
readonly offlineIndicatorDelayMs?: number;
|
||||||
|
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
|
||||||
|
* only. */
|
||||||
|
readonly revalidationIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only
|
||||||
|
* an outage that outlasts this is worth the corner indicator. */
|
||||||
|
export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000;
|
||||||
|
|
||||||
|
/** A module whose requests are being refused prompts at most one revalidation in this interval. A
|
||||||
|
* display that is merely missing one permission must not turn every refused request into two more. */
|
||||||
|
export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally:
|
||||||
|
* the descriptor and principal are small, server-produced, and carry nothing volatile, so any
|
||||||
|
* difference — a new version, a new contribution, a different principal — is a real one. */
|
||||||
|
function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean {
|
||||||
|
return (
|
||||||
|
JSON.stringify([left.descriptor, left.principal]) ===
|
||||||
|
JSON.stringify([right.descriptor, right.principal])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */
|
||||||
|
function useSustained(value: boolean, delayMs: number): boolean {
|
||||||
|
const [sustained, setSustained] = useState(value && delayMs <= 0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setSustained(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delayMs <= 0) {
|
||||||
|
setSustained(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setSustained(true), delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delayMs, value]);
|
||||||
|
return sustained;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDenied(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof HostProtocolError &&
|
||||||
|
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayStyles = {
|
const displayStyles = {
|
||||||
@@ -86,11 +140,13 @@ const displayStyles = {
|
|||||||
* recovers on its own the moment connectivity returns.
|
* recovers on its own the moment connectivity returns.
|
||||||
*/
|
*/
|
||||||
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
|
function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement {
|
||||||
|
// Neutral wording: this is on a wall the congregation reads, and the product name is the
|
||||||
|
// installation's to choose, not the platform's (ADR 0046).
|
||||||
const label = props.connectivity.usingFallback
|
const label = props.connectivity.usingFallback
|
||||||
? props.connectivity.fallbackKind === "artifact"
|
? props.connectivity.fallbackKind === "artifact"
|
||||||
? "Offline — showing the last downloaded display code"
|
? "Offline — showing the last downloaded display code"
|
||||||
: "Offline — showing the last downloaded Ekklesia display"
|
: "Offline — showing the last downloaded display"
|
||||||
: "Offline — waiting for Ekklesia";
|
: "Offline — reconnecting";
|
||||||
return (
|
return (
|
||||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||||
{label}
|
{label}
|
||||||
@@ -130,7 +186,83 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
online: true,
|
online: true,
|
||||||
usingFallback: false,
|
usingFallback: false,
|
||||||
});
|
});
|
||||||
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
|
const {
|
||||||
|
config,
|
||||||
|
connectivity: connectivitySource,
|
||||||
|
importModule,
|
||||||
|
moduleId,
|
||||||
|
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
|
||||||
|
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
||||||
|
} = props;
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
|
||||||
|
inFlight: undefined,
|
||||||
|
lastAt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the descriptor again **without** taking the running display down (ADR 0043).
|
||||||
|
*
|
||||||
|
* A display that is showing something keeps showing it while the question is asked. Only the
|
||||||
|
* answer decides what happens: the same display means nothing changes at all; a new version or a
|
||||||
|
* different principal replaces the frame; a refusal is a revoked or expired device and returns the
|
||||||
|
* screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame
|
||||||
|
* stays. A display that is not showing anything simply starts over.
|
||||||
|
*/
|
||||||
|
const revalidate = useCallback(
|
||||||
|
(reason: "online" | "authorization"): void => {
|
||||||
|
const current = stateRef.current;
|
||||||
|
if (current.status !== "ready") {
|
||||||
|
if (reason === "online") {
|
||||||
|
setAttempt((value) => value + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = revalidation.current;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (
|
||||||
|
tracker.inFlight !== undefined ||
|
||||||
|
(reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracker.lastAt = nowMs;
|
||||||
|
const abort = new AbortController();
|
||||||
|
tracker.inFlight = abort;
|
||||||
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
||||||
|
.then((next) => {
|
||||||
|
const latest = stateRef.current;
|
||||||
|
if (abort.signal.aborted || latest.status !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sameDisplay(latest.bootstrap, next)) {
|
||||||
|
setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!abort.signal.aborted && isDenied(error)) {
|
||||||
|
setState({ status: "pairing" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (tracker.inFlight === abort) {
|
||||||
|
tracker.inFlight = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config, moduleId, revalidationIntervalMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
revalidation.current.inFlight?.abort();
|
||||||
|
revalidation.current.inFlight = undefined;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectivitySource) {
|
if (!connectivitySource) {
|
||||||
@@ -142,27 +274,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
setConnectivity(next);
|
setConnectivity(next);
|
||||||
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
|
// Nobody presses Retry on a lobby television. When the browser says the network is back, the
|
||||||
// display resolves its descriptor again — which is also what stops a screen from sitting on
|
// display resolves its descriptor again — which is also what stops a screen from sitting on
|
||||||
// cached code after the server returns, since the fresh descriptor names the current version
|
// cached code after the server returns, since a fresh descriptor naming a new version replaces
|
||||||
// and the network-first worker fetches it (ADR 0043).
|
// the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the
|
||||||
|
// running display exactly where it was.
|
||||||
if (next.online) {
|
if (next.online) {
|
||||||
setAttempt((current) => current + 1);
|
revalidate("online");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
monitor.close();
|
monitor.close();
|
||||||
};
|
};
|
||||||
}, [connectivitySource]);
|
}, [connectivitySource, revalidate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
revalidation.current.inFlight?.abort();
|
||||||
setState({ generation: attempt, status: "loading" });
|
setState({ generation: attempt, status: "loading" });
|
||||||
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
void bootstrapDisplayHost(config, moduleId, abort.signal)
|
||||||
.then((bootstrap) => {
|
.then((bootstrap) => {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState({ bootstrap, status: "ready" });
|
setState({ bootstrap, generation: attempt, status: "ready" });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (abort.signal.aborted) {
|
if (abort.signal.aborted) {
|
||||||
@@ -170,10 +304,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
}
|
}
|
||||||
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
|
// A denied or unauthenticated descriptor is the ordinary state of a screen nobody has
|
||||||
// paired yet, not an error worth logging on every wall in the building.
|
// paired yet, not an error worth logging on every wall in the building.
|
||||||
if (
|
if (isDenied(error)) {
|
||||||
error instanceof HostProtocolError &&
|
|
||||||
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
|
||||||
) {
|
|
||||||
setState({ status: "pairing" });
|
setState({ status: "pairing" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,12 +319,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
}, [attempt, config, moduleId]);
|
}, [attempt, config, moduleId]);
|
||||||
|
|
||||||
const retry = (): void => setAttempt((current) => current + 1);
|
const retry = (): void => setAttempt((current) => current + 1);
|
||||||
|
const showOffline = useSustained(
|
||||||
|
!connectivity.online || connectivity.usingFallback,
|
||||||
|
offlineIndicatorDelayMs,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="display-host" style={displayStyles.host}>
|
<div data-testid="display-host" style={displayStyles.host}>
|
||||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
||||||
<OfflineIndicator connectivity={connectivity} />
|
|
||||||
)}
|
|
||||||
{state.status === "loading" ? (
|
{state.status === "loading" ? (
|
||||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -215,7 +348,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
config={config}
|
config={config}
|
||||||
descriptor={state.bootstrap.descriptor}
|
descriptor={state.bootstrap.descriptor}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
|
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
|
||||||
|
onAuthorizationResponse={onAuthorizationResponse}
|
||||||
principal={state.bootstrap.principal}
|
principal={state.bootstrap.principal}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
|
|||||||
readonly principal: ModuleUiPrincipal;
|
readonly principal: ModuleUiPrincipal;
|
||||||
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||||
readonly importModule?: ModuleUiImporter;
|
readonly importModule?: ModuleUiImporter;
|
||||||
|
/** Host-internal signal that a module request was refused, so the host can find out whether this
|
||||||
|
* device was revoked. It never enters the module-facing context. */
|
||||||
|
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
|
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
|
||||||
@@ -94,7 +97,10 @@ const frameStyles = {
|
|||||||
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||||
const { config, descriptor, importModule, onRetry, principal } = props;
|
const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props;
|
||||||
|
// Read through a ref so a new callback identity can never remount a running display.
|
||||||
|
const authorizationRef = useRef(onAuthorizationResponse);
|
||||||
|
authorizationRef.current = onAuthorizationResponse;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
|||||||
context: createModuleUiContext({
|
context: createModuleUiContext({
|
||||||
config,
|
config,
|
||||||
descriptor,
|
descriptor,
|
||||||
|
onAuthorizationResponse: (status) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
authorizationRef.current?.(status);
|
||||||
|
}
|
||||||
|
},
|
||||||
principal,
|
principal,
|
||||||
...(importModule ? { importModule } : {}),
|
...(importModule ? { importModule } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -180,6 +180,12 @@ async function render(
|
|||||||
importModule?: (url: string) => Promise<unknown>,
|
importModule?: (url: string) => Promise<unknown>,
|
||||||
moduleId = "announcements",
|
moduleId = "announcements",
|
||||||
connectivity?: DisplayConnectivitySource,
|
connectivity?: DisplayConnectivitySource,
|
||||||
|
timing: {
|
||||||
|
readonly offlineIndicatorDelayMs?: number;
|
||||||
|
readonly revalidationIntervalMs?: number;
|
||||||
|
} = {
|
||||||
|
offlineIndicatorDelayMs: 0,
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -187,6 +193,7 @@ async function render(
|
|||||||
config={config}
|
config={config}
|
||||||
{...(connectivity ? { connectivity } : {})}
|
{...(connectivity ? { connectivity } : {})}
|
||||||
{...(importModule ? { importModule } : {})}
|
{...(importModule ? { importModule } : {})}
|
||||||
|
{...timing}
|
||||||
moduleId={moduleId}
|
moduleId={moduleId}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -926,3 +933,232 @@ describe("display host — offline behaviour", () => {
|
|||||||
expect(query("display-pairing")).not.toBeNull();
|
expect(query("display-pairing")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing.
|
||||||
|
*
|
||||||
|
* Before these, every browser `online` event tore the module down and mounted it again (a fresh
|
||||||
|
* viewer session, a fresh stage, every cached object URL released), and a device revoked while its
|
||||||
|
* display was open kept playing until someone reloaded the page.
|
||||||
|
*/
|
||||||
|
describe("display host — continuity across reconnects and revocation", () => {
|
||||||
|
function controllableConnectivity() {
|
||||||
|
const listeners = new Map<string, Set<() => void>>();
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
addEventListener(type: "online" | "offline", listener: () => void) {
|
||||||
|
const existing = listeners.get(type) ?? new Set();
|
||||||
|
existing.add(listener);
|
||||||
|
listeners.set(type, existing);
|
||||||
|
},
|
||||||
|
initialOnline: true,
|
||||||
|
removeEventListener(type: "online" | "offline", listener: () => void) {
|
||||||
|
listeners.get(type)?.delete(listener);
|
||||||
|
},
|
||||||
|
} satisfies DisplayConnectivitySource,
|
||||||
|
async emit(type: "online" | "offline") {
|
||||||
|
await act(async () => {
|
||||||
|
for (const listener of listeners.get(type) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */
|
||||||
|
function changingServer() {
|
||||||
|
let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 };
|
||||||
|
let descriptorCalls = 0;
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v1/module-displays/announcements")) {
|
||||||
|
descriptorCalls += 1;
|
||||||
|
return json(descriptor.body, descriptor.status);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/device-pairing/requests")) {
|
||||||
|
return json(
|
||||||
|
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/device-pairing/claims")) {
|
||||||
|
return json({ status: "pending" });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/modules/announcements/refused")) {
|
||||||
|
return json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||||
|
}
|
||||||
|
return json({ code: "NOT_FOUND" }, 404);
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
return {
|
||||||
|
descriptorCalls: () => descriptorCalls,
|
||||||
|
fetch,
|
||||||
|
set(status: number, body: unknown) {
|
||||||
|
descriptor = { body, status };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countingArtifact(version = "1.2.0") {
|
||||||
|
const counts = { mounts: 0, unmounts: 0 };
|
||||||
|
let context: ModuleUiHostContext | undefined;
|
||||||
|
const importModule = async (url: string) =>
|
||||||
|
artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, {
|
||||||
|
mount(element, mounted) {
|
||||||
|
counts.mounts += 1;
|
||||||
|
context = mounted;
|
||||||
|
element.textContent = "live";
|
||||||
|
return () => {
|
||||||
|
counts.unmounts += 1;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { context: () => context, counts, importModule };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not remount a live display when the network drops and returns", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const connectivity = controllableConnectivity();
|
||||||
|
const display = countingArtifact();
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: server.fetch },
|
||||||
|
display.importModule,
|
||||||
|
"announcements",
|
||||||
|
connectivity.source,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(display.counts.mounts).toBe(1);
|
||||||
|
const before = server.descriptorCalls();
|
||||||
|
|
||||||
|
await connectivity.emit("offline");
|
||||||
|
await connectivity.emit("online");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing.
|
||||||
|
expect(server.descriptorCalls()).toBeGreaterThan(before);
|
||||||
|
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
|
||||||
|
expect(query("display-host-loading")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the running display when the descriptor cannot be reached yet", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const connectivity = controllableConnectivity();
|
||||||
|
const display = countingArtifact();
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: server.fetch },
|
||||||
|
display.importModule,
|
||||||
|
"announcements",
|
||||||
|
connectivity.source,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
server.set(503, { code: "UNAVAILABLE" });
|
||||||
|
await connectivity.emit("offline");
|
||||||
|
await connectivity.emit("online");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
|
||||||
|
expect(query("display-host-error")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the display when the server now names a new version", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const connectivity = controllableConnectivity();
|
||||||
|
const display = countingArtifact();
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: server.fetch },
|
||||||
|
display.importModule,
|
||||||
|
"announcements",
|
||||||
|
connectivity.source,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
server.set(200, descriptorPayload("announcements", "2.0.0"));
|
||||||
|
await connectivity.emit("offline");
|
||||||
|
await connectivity.emit("online");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(display.counts.unmounts).toBe(1);
|
||||||
|
expect(display.counts.mounts).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a device revoked while its display is open to pairing, without a reload", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const display = countingArtifact();
|
||||||
|
await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements");
|
||||||
|
await flush();
|
||||||
|
expect(query("display-module-container")).not.toBeNull();
|
||||||
|
|
||||||
|
// The administrator revokes the device. Its next module request is refused.
|
||||||
|
server.set(403, { code: "AUTHORIZATION_DENIED" });
|
||||||
|
await act(async () => {
|
||||||
|
await display.context()?.api.request("refused");
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(display.counts.unmounts).toBe(1);
|
||||||
|
expect(query("display-pairing")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remount when a refused request turns out not to be a revocation", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const display = countingArtifact();
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: server.fetch },
|
||||||
|
display.importModule,
|
||||||
|
"announcements",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
offlineIndicatorDelayMs: 0,
|
||||||
|
revalidationIntervalMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
const before = server.descriptorCalls();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await display.context()?.api.request("refused");
|
||||||
|
await display.context()?.api.request("refused");
|
||||||
|
await display.context()?.api.request("refused");
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// One revalidation for the burst, and the display stays exactly where it was.
|
||||||
|
expect(server.descriptorCalls()).toBe(before + 1);
|
||||||
|
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the offline indicator for a momentary blip", async () => {
|
||||||
|
const server = changingServer();
|
||||||
|
const connectivity = controllableConnectivity();
|
||||||
|
await render(
|
||||||
|
{ apiBaseUrl, fetch: server.fetch },
|
||||||
|
countingArtifact().importModule,
|
||||||
|
"announcements",
|
||||||
|
connectivity.source,
|
||||||
|
{ offlineIndicatorDelayMs: 60 },
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await connectivity.emit("offline");
|
||||||
|
await connectivity.emit("online");
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
});
|
||||||
|
expect(query("display-offline")).toBeNull();
|
||||||
|
|
||||||
|
await connectivity.emit("offline");
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
});
|
||||||
|
expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user