171 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|