import type { ModuleContributionDescriptor, ModuleUiApiClient, ModuleUiContributions, ModuleUiHostContext, MountedModuleContribution, MountModuleContributionOptions, } from "@ekklesia/module-ui-sdk"; /** * A host, as a module's artifact actually experiences one. * * Requests are matched on the **relative** path a module asks for, which is what proves the artifact * never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would * simply never match. */ export interface FakeHostRoute { readonly method: string; readonly path: string | RegExp; readonly respond: (request: { readonly path: string; readonly init: RequestInit | undefined; }) => Response | Promise; } export interface FakeHost { readonly context: ModuleUiHostContext; readonly requests: { method: string; path: string }[]; /** Contributions this host published against Signage's renderer capability. */ readonly mounted: { contributionId: string; input: unknown }[]; } export function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status, }); } export function errorResponse( code: string, message: string, status = 400, ): Response { return jsonResponse({ code, message }, status); } export interface FakeHostOptions { readonly permissions: readonly string[]; readonly routes: readonly FakeHostRoute[]; readonly contributions?: readonly ModuleContributionDescriptor[]; readonly displayUrl?: string; readonly principal?: ModuleUiHostContext["principal"]; readonly mount?: ( options: MountModuleContributionOptions, ) => Promise | MountedModuleContribution; } export function createFakeHost(options: FakeHostOptions): FakeHost { const requests: { method: string; path: string }[] = []; const mounted: { contributionId: string; input: unknown }[] = []; const api: ModuleUiApiClient = { async request(path, init) { const method = (init?.method ?? "GET").toUpperCase(); const normalized = path.replace(/^\//u, ""); requests.push({ method, path: normalized }); for (const route of options.routes) { const matches = typeof route.path === "string" ? route.path === normalized.split("?")[0] : route.path.test(normalized); if (route.method === method && matches) { return route.respond({ init, path: normalized }); } } return errorResponse( "SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404, ); }, }; const contributions: ModuleUiContributions = { list: (capabilityId) => capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], async mount(mountOptions) { mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input, }); if (options.mount) { return options.mount(mountOptions); } return { contributionId: mountOptions.contributionId, mounted: true, unmount: () => Promise.resolve(), update: () => Promise.resolve(), version: "1.0.0", }; }, }; return { context: { api, contributions, display: { url: options.displayUrl ?? "https://api.example.test/display/signage", }, moduleId: "signage", permissions: { has: (permission) => options.permissions.includes(permission), }, principal: options.principal ?? { kind: "anonymous" }, realtime: { subscribe: () => ({ close: () => undefined }) }, }, mounted, requests, }; } /** The settings payload every screen loads first. */ export const FAKE_SETTINGS = Object.freeze({ crossfadeMs: 1000, moduleVersion: "0.1.2", nativeImageDurationMs: 7000, nativePresentation: { backgroundColor: "#000000", backgroundDarkened: false, backgroundMode: "blurred", blurPixels: 20, }, rendererInputVersion: "1.0.0", timezone: "America/Chicago", uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000, }, });