// @vitest-environment happy-dom import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseModuleManifest } from "@ekklesia/module-sdk"; import { buildModuleUi } from "@ekklesia/module-ui-build"; import { parseModuleUiExport } from "@ekklesia/module-ui-sdk"; import { beforeAll, describe, expect, it } from "vitest"; import { createFakeHost, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; /** * The end-to-end proof for Signage's browser artifacts: * * React/TSX source -> `pnpm build` -> the artifacts the manifest declares -> `parseModuleUiExport` * -> `mount(container, context)` -> `unmount()` * * Both artifacts are rebuilt from source here rather than read from a checked-in file, so this can * never pass against stale output. `dist/` is gitignored; nothing generated is committed. */ // `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so // this test works in plain filesystem paths and converts to a file URL only for dynamic import. const moduleDirectory = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); let identity: { id: string; version: string }; let uiArtifactPath: string; let uiArtifactHref: string; let displayArtifactPath: string; let displayArtifactHref: string; async function flush(): Promise { for (let index = 0; index < 8; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } describe("Signage browser artifacts", () => { beforeAll(async () => { const manifest = parseModuleManifest( JSON.parse( await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"), ) as unknown, ); identity = { id: manifest.id, version: manifest.version }; const result = await buildModuleUi({ moduleDirectory }); const ui = result.artifacts.find((artifact) => artifact.kind === "ui"); const display = result.artifacts.find((artifact) => artifact.kind === "display"); if (!ui || !display) { throw new Error("The Signage build must produce both declared browser artifacts."); } uiArtifactPath = ui.outputFile; uiArtifactHref = pathToFileURL(ui.outputFile).href; displayArtifactPath = display.outputFile; displayArtifactHref = pathToFileURL(display.outputFile).href; }, 180_000); it("produces self-contained artifacts that bake in no Ekklesia deployment detail", async () => { const sources = await Promise.all([ readFile(uiArtifactPath, "utf8"), readFile(displayArtifactPath, "utf8"), ]); for (const source of sources) { // React is bundled, so each artifact resolves nothing at run time (ADR 0023). expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu); expect(source).toContain("export {"); expect(source).not.toContain("/api/v1/modules"); expect(source).not.toContain("/api/v1/module-ui"); expect(source).not.toContain("process.env.NODE_ENV"); // Nothing this release excludes may reach a browser artifact. for (const forbidden of ["mqtt", "socket.io", "homeassistant", "sqlite"]) { expect(source.toLowerCase()).not.toContain(forbidden); } } }); it("mounts the management UI, asks for relative paths only, and unmounts", async () => { const namespace = (await import(uiArtifactHref)) as unknown; const definition = parseModuleUiExport(namespace, identity); const container = document.createElement("div"); document.body.append(container); const host = createFakeHost({ permissions: ["signage.view"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [] }) }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, ], }); const unmount = await definition.mount(container, host.context); await flush(); expect(container.textContent).toContain("Playback"); // Relative to Signage's own API root, always. A route table keyed on Ekklesia's private prefix // would never have matched. for (const request of host.requests) { expect(request.path.startsWith("api/")).toBe(false); expect(request.path.startsWith("/")).toBe(false); } // The desktop-only gate is a CSS decision, so both branches are always in the accessibility tree. expect(container.querySelector("[data-testid='signage-narrow-notice']")).not.toBeNull(); await (unmount as () => Promise)(); expect(container.textContent).toBe(""); container.remove(); }); it("renders a denied state from the host's presentation-only permission hint", async () => { const namespace = (await import(uiArtifactHref)) as unknown; const definition = parseModuleUiExport(namespace, identity); const container = document.createElement("div"); const host = createFakeHost({ permissions: [], routes: [] }); const unmount = await definition.mount(container, host.context); await flush(); expect(container.textContent).toContain("No access to Signage"); // A hidden control is a usability choice, not enforcement: Signage simply did not ask. expect(host.requests).toEqual([]); await (unmount as () => Promise)(); }); it("mounts the display through the same ABI and shows the safe slate", async () => { const namespace = (await import(displayArtifactHref)) as unknown; const definition = parseModuleUiExport(namespace, identity); const container = document.createElement("div"); const host = createFakeHost({ permissions: ["signage.display.view"], routes: [] }); const unmount = await definition.mount(container, host.context); await flush(); const slate = container.querySelector("[data-testid='signage-slate']"); expect(slate?.textContent).toBe("Content unavailable"); // The slate must never carry an exception, URL, identifier, or payload of any kind. expect(slate?.textContent).not.toMatch(/https?:|\/api\/|[0-9a-f]{8}-[0-9a-f]{4}/u); await (unmount as () => Promise)(); expect(container.textContent).toBe(""); }); it("carries each artifact's own stylesheet inside the one declared file", async () => { const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity); const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity); expect(ui.styles).toContain(".signage-narrow-notice"); expect(ui.styles).toContain(".ek-panel"); // Signage references the platform's global theme and never declares part of it (ADR 0039). expect(ui.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u); expect(display.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u); // Two artifacts of one module: separate roots, separate stylesheets. expect(display.styles).toContain(".signage-slate"); expect(display.styles).not.toContain(".signage-narrow-notice"); // No sidecar stylesheet is emitted beside either artifact. await expect(readFile(path.join(path.dirname(uiArtifactPath), "index.css"))).rejects.toThrow(); }); it("sets its own box model, because an isolated root inherits none", async () => { const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity); const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity); // The host installs base rules and the platform's components, and neither declares `box-sizing`. // Under the initial `content-box` a control that is `width: 100%` is laid out wider than the // field around it by its own padding and border, and slides under whatever sits beside it. Each // artifact says so for the tree it owns; nothing outside that tree is affected. for (const styles of [ui.styles, display.styles]) { expect(styles).toMatch(/\*,\s*\*::before,\s*\*::after\s*\{[^}]*box-sizing:\s*border-box/u); } }); });