feat: add display kiosk host
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import {
|
||||
bootstrapDisplayHost,
|
||||
bootstrapHost,
|
||||
createModuleUiContext,
|
||||
type EkklesiaHostConfig,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
@@ -84,10 +86,14 @@ async function createFixtureModule(
|
||||
modulesDirectory: string,
|
||||
id = "example",
|
||||
name = "Example module",
|
||||
withDisplay = false,
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
if (withDisplay) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
@@ -102,9 +108,20 @@ async function createFixtureModule(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
ui: "./dist/ui/index.js",
|
||||
...(withDisplay
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: `${id}.display.view`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -112,6 +129,15 @@ async function createFixtureModule(
|
||||
description: "Read example records.",
|
||||
id: `${id}.records.read`,
|
||||
},
|
||||
...(withDisplay
|
||||
? [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the example display.",
|
||||
id: `${id}.display.view`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
@@ -154,9 +180,26 @@ async function createFixtureModule(
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
if (withDisplay) {
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
const response = await context.api.request("records");
|
||||
const records = response.ok ? await response.json() : [];
|
||||
container.textContent =
|
||||
context.moduleId + ":display:" + response.status + ":" + records.length;
|
||||
return () => { container.textContent = ""; };
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface HarnessOptions {
|
||||
readonly display?: boolean;
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly grants?: readonly string[];
|
||||
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
||||
@@ -178,7 +221,7 @@ interface Harness {
|
||||
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await createFixtureModule(modulesDirectory);
|
||||
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
|
||||
for (const extra of options.extraModules ?? []) {
|
||||
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||
}
|
||||
@@ -214,12 +257,21 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const moduleUi = createModuleUiPublicationRegistry(modules);
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleUi,
|
||||
...(moduleUi.listDisplays().length === 0
|
||||
? {}
|
||||
: {
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||
"export const displayHost = true;\n",
|
||||
),
|
||||
}),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
@@ -326,6 +378,81 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
|
||||
const harness = await startHarness({
|
||||
display: true,
|
||||
grants: ["example.records.read", "example.display.view"],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
|
||||
// not a test-only HTML wrapper around the browser functions below.
|
||||
const page = await (harness.config.fetch as typeof fetch)(
|
||||
"https://ekklesia.example.test/display/example",
|
||||
);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
|
||||
|
||||
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
|
||||
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
|
||||
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
|
||||
expect(bootstrap.status).toBe("ready");
|
||||
if (bootstrap.status !== "ready") {
|
||||
throw new Error("the authenticated display did not resolve");
|
||||
}
|
||||
expect(bootstrap.descriptor).toEqual({
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read", "example.display.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
|
||||
// those returned bytes. Reading the fixture file directly would not prove publication.
|
||||
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
|
||||
expect(sourceUrl).toBe(
|
||||
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
|
||||
);
|
||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||
|
||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
||||
// display artifact independently of the Dashboard artifact.
|
||||
const context = createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor: bootstrap.descriptor,
|
||||
principal: bootstrap.principal,
|
||||
});
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
const container = containerSink();
|
||||
const mounted = await loadModuleUi({
|
||||
container: container as unknown as LoaderContainer,
|
||||
context,
|
||||
source: {
|
||||
moduleId: bootstrap.descriptor.id,
|
||||
url: artifactUrl,
|
||||
version: bootstrap.descriptor.version,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("example:display:200:2");
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
|
||||
// Signed in, but holding no grant for the module's restricted permission.
|
||||
const harness = await startHarness({
|
||||
|
||||
Reference in New Issue
Block a user