The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type EkklesiaPrincipal,
|
|
type ModuleDatabase,
|
|
type ModuleRuntimeServices,
|
|
parseEkklesiaUserId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
createAuthorizationEvaluator,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
} from "@ekklesia/core/modules";
|
|
import type { Hono } from "hono";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createApiApp } from "../src/app.js";
|
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
|
import {
|
|
DISPLAY_HOST_SCRIPT_PATH,
|
|
ModuleDisplayConfigurationError,
|
|
moduleDisplayDescriptorUrl,
|
|
moduleDisplayPageUrl,
|
|
} from "../src/module-display-routes.js";
|
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
|
import {
|
|
createInMemoryModuleLifecycleStore,
|
|
moduleLifecycleSnapshotFor,
|
|
} from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
|
|
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const displayPermission = "announcements.display.view";
|
|
|
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
|
);
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
|
);
|
|
});
|
|
|
|
interface ModuleFixture {
|
|
readonly display?: boolean;
|
|
readonly id?: string;
|
|
readonly ui?: boolean;
|
|
readonly version?: string;
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
|
const id = fixture.id ?? "announcements";
|
|
const version = fixture.version ?? "1.4.2";
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
|
...(fixture.display
|
|
? {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: `${id}.display.view`,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
id,
|
|
manifestVersion: 7,
|
|
name: `${id} module`,
|
|
permissions: fixture.display
|
|
? [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View the ${id} display.`,
|
|
id: `${id}.display.view`,
|
|
},
|
|
]
|
|
: [],
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
|
);
|
|
if (fixture.ui) {
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
|
);
|
|
}
|
|
if (fixture.display) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/display/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
interface InstallOptions extends ModuleFixture {
|
|
readonly assets?: DisplayHostAssets;
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
readonly granted?: readonly string[];
|
|
readonly principal?: EkklesiaPrincipal;
|
|
/** The API builds publication from the eligible/running set. `false` models a module that was
|
|
* discovered but disabled or dependency-blocked before runtime startup. */
|
|
readonly published?: boolean;
|
|
readonly withAssets?: boolean;
|
|
}
|
|
|
|
interface Installation {
|
|
readonly app: Hono;
|
|
readonly modules: readonly DiscoveredModule[];
|
|
}
|
|
|
|
function fakeServices(): ModuleRuntimeServices {
|
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
|
}
|
|
|
|
function fakeAuthorizationStore(): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
} as unknown as AuthorizationStore;
|
|
}
|
|
|
|
async function install(options: InstallOptions): Promise<Installation> {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await createModule(modulesDirectory, options);
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
const granted = new Set(options.granted ?? []);
|
|
const evaluator =
|
|
options.evaluator ??
|
|
createAuthorizationEvaluator({
|
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
|
platformOwners: { isPlatformOwner: async () => false },
|
|
});
|
|
const runtime: EkklesiaIdentityRuntime = {
|
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
|
};
|
|
const publishedModules = options.published === false ? [] : modules;
|
|
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
|
const app = createApiApp({
|
|
authorization: {
|
|
evaluator,
|
|
registry: composePlatformPermissionRegistry(modules),
|
|
store: fakeAuthorizationStore(),
|
|
},
|
|
dashboard: createInMemoryDashboardStore(),
|
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
|
: {}),
|
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleServices: fakeServices(),
|
|
moduleStorage: moduleStorageOptionFor(modules),
|
|
moduleUi,
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
return { app, modules };
|
|
}
|
|
|
|
function signedIn(): EkklesiaPrincipal {
|
|
return createAuthenticatedPrincipal(viewerId);
|
|
}
|
|
|
|
describe("module display routes — authorization and descriptor", () => {
|
|
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const [page, descriptor] = await Promise.all([
|
|
app.request(moduleDisplayPageUrl("announcements")),
|
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
|
]);
|
|
|
|
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
|
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
|
// the same module-agnostic bytes every display gets, with no module id and no data.
|
|
expect(page.status).toBe(200);
|
|
expect(await page.text()).toBe(displayHostAssets.html);
|
|
|
|
// Authorization is entirely in the descriptor, and it has not moved.
|
|
expect(descriptor.status).toBe(403);
|
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
});
|
|
|
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
|
const { app } = await install({ display: true, principal: signedIn() });
|
|
|
|
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
|
});
|
|
|
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
version: "1.4.2",
|
|
});
|
|
|
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
|
|
|
expect(page.status).toBe(200);
|
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(await page.text()).toBe(displayHostAssets.html);
|
|
expect(await descriptor.json()).toEqual({
|
|
viewer: { kind: "authenticated", userId: viewerId },
|
|
module: {
|
|
contributions: [],
|
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
|
display: {
|
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
|
},
|
|
id: "announcements",
|
|
permissions: [displayPermission],
|
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
|
version: "1.4.2",
|
|
},
|
|
});
|
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
|
});
|
|
|
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
|
const authorize = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ allowed: true })
|
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
|
const evaluator: AuthorizationEvaluator = { authorize };
|
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
|
|
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
|
const body = await response.text();
|
|
|
|
expect(authorize).toHaveBeenCalledTimes(2);
|
|
expect(response.status).toBe(503);
|
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("vary")).toContain("Origin");
|
|
expect(response.headers.get("vary")).toContain("Cookie");
|
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
|
expect(body).not.toContain(secret);
|
|
}
|
|
});
|
|
|
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("module display routes — role and lifecycle separation", () => {
|
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
expect(inventory.status).toBe(200);
|
|
expect(await inventory.json()).toEqual({ modules: [] });
|
|
});
|
|
|
|
it("does not give a dashboard-only module a display route", async () => {
|
|
const { app } = await install({ principal: signedIn(), ui: true });
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
expect(inventory.status).toBe(200);
|
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
|
modules: [{ id: "announcements" }],
|
|
});
|
|
});
|
|
|
|
it("publishes both roles independently when a module declares both", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
ui: true,
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
|
expect(
|
|
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
|
.modules,
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
published: false,
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
|
expect(
|
|
(
|
|
await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
|
)
|
|
).status,
|
|
).toBe(404);
|
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
|
});
|
|
|
|
it("registers only the exact stable page and descriptor paths", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
for (const route of [
|
|
"/display",
|
|
"/display/announcements/",
|
|
"/display/announcements/settings",
|
|
"/api/v1/module-displays",
|
|
"/api/v1/module-displays/announcements/",
|
|
"/api/v1/module-displays/announcements/settings",
|
|
]) {
|
|
expect((await app.request(route)).status, route).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
|
ModuleDisplayConfigurationError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("module display host asset publication", () => {
|
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(displayHostAssets.script);
|
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
|
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
|
});
|
|
expect(notModified.status).toBe(304);
|
|
expect(await notModified.text()).toBe("");
|
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
|
|
|
for (const header of [
|
|
`W/${displayHostAssets.scriptEtag}`,
|
|
"*",
|
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
|
]) {
|
|
expect(
|
|
(
|
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": header },
|
|
})
|
|
).status,
|
|
header,
|
|
).toBe(304);
|
|
}
|
|
expect(
|
|
(
|
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": '"stale"' },
|
|
})
|
|
).status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
for (const candidate of [
|
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
|
"/assets/ekklesia-display/index.html",
|
|
"/assets/ekklesia-display/secret.txt",
|
|
"/assets/ekklesia-display/../secret.txt",
|
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
|
]) {
|
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
|
const first = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
const second = await install({
|
|
display: true,
|
|
granted: ["signage.display.view"],
|
|
id: "signage",
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
|
expect(firstHtml).toBe(secondHtml);
|
|
expect(firstHtml).not.toContain("announcements");
|
|
expect(secondHtml).not.toContain("signage");
|
|
});
|
|
|
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
|
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
|
const mutable = { ...source };
|
|
const { app } = await install({ assets: mutable, display: true });
|
|
mutable.html = "<html>mutated secret</html>";
|
|
mutable.script = "throw new Error('mutated secret');";
|
|
|
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
|
|
|
await expect(
|
|
install({
|
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
|
display: true,
|
|
}),
|
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
|
});
|
|
|
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
|
const { app } = await install({ ui: true, withAssets: false });
|
|
|
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
|
});
|
|
});
|