Files
Signage/tests/browser/module-ui.test.ts
2026-08-28 16:41:54 -05:00

171 lines
7.9 KiB
TypeScript

// @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<void> {
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<void>)();
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<void>)();
});
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<void>)();
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);
}
});
});