Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { build } from "esbuild";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* The proof that this shell is a *generic* host rather than an application that happens to render
|
|
* some known modules.
|
|
*
|
|
* Two levels, because they fail differently. At source level, a static import of a module would be
|
|
* an architectural mistake a reviewer should catch. At bundle level, a dynamic import that a bundler
|
|
* managed to resolve statically would be an invisible one: the code would look correct, the tests
|
|
* would pass, and installing a new module would silently do nothing until the shell was rebuilt.
|
|
*
|
|
* `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
|
|
* everything here works in plain filesystem paths.
|
|
*/
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const sourceDirectory = path.join(packageDirectory, "src");
|
|
|
|
/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic
|
|
* loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package
|
|
* — and no module — may appear. */
|
|
const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]);
|
|
|
|
async function sourceFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await sourceFiles(entryPath)));
|
|
} else if (/\.tsx?$/u.test(entry.name)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
function importSpecifiers(source: string): string[] {
|
|
const specifiers = new Set<string>();
|
|
const patterns = [
|
|
/\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu,
|
|
/\bimport\s*["']([^"'\n]+)["']/gu,
|
|
/\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
|
|
];
|
|
for (const pattern of patterns) {
|
|
for (const match of source.matchAll(pattern)) {
|
|
if (match[1]) {
|
|
specifiers.add(match[1]);
|
|
}
|
|
}
|
|
}
|
|
return [...specifiers];
|
|
}
|
|
|
|
let files: string[];
|
|
let sources: Map<string, string>;
|
|
|
|
beforeAll(async () => {
|
|
files = await sourceFiles(sourceDirectory);
|
|
sources = new Map(
|
|
await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)),
|
|
);
|
|
});
|
|
|
|
describe("the shell is coupled to no module", () => {
|
|
it("has source files to check", () => {
|
|
expect(files.length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it("imports no module, template, or workspace package beyond the two browser contracts", () => {
|
|
for (const [file, source] of sources) {
|
|
for (const specifier of importSpecifiers(source)) {
|
|
const label = `${path.relative(packageDirectory, file)} -> ${specifier}`;
|
|
expect(specifier.includes("modules/"), label).toBe(false);
|
|
expect(specifier.includes("templates/"), label).toBe(false);
|
|
// Nothing may climb out of this package at all.
|
|
expect(specifier.startsWith("../../"), label).toBe(false);
|
|
if (specifier.startsWith("@ekklesia/")) {
|
|
expect(ALLOWED_EKKLESIA_IMPORTS.has(specifier), label).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
|
|
for (const [file, source] of sources) {
|
|
// Comment lines are dropped so a doc comment may still *describe* the loader's own
|
|
// `(url) => import(url)`. The authoritative check is the bundle-level one below, which counts
|
|
// dynamic imports in the emitted output where no comment survives.
|
|
const code = source
|
|
.split("\n")
|
|
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
|
|
.join("\n");
|
|
const dynamicImports = [...code.matchAll(/\bimport\s*\(/gu)];
|
|
expect(dynamicImports, path.relative(packageDirectory, file)).toEqual([]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the shell builds no module knowledge into its bundle", () => {
|
|
let bundle: string;
|
|
|
|
beforeAll(async () => {
|
|
// The real production bundle configuration, produced in memory so this test never depends on a
|
|
// previous `pnpm build` having run.
|
|
const result = await build({
|
|
absWorkingDir: packageDirectory,
|
|
bundle: true,
|
|
define: {
|
|
__EKKLESIA_API_BASE_URL__: '""',
|
|
"process.env.NODE_ENV": '"production"',
|
|
},
|
|
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
logLevel: "silent",
|
|
minify: false,
|
|
platform: "browser",
|
|
target: "es2022",
|
|
write: false,
|
|
});
|
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
|
}, 60_000);
|
|
|
|
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
|
|
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
|
|
|
|
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
|
|
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
|
|
// could never be fetched at run time.
|
|
expect(dynamicImports).toHaveLength(1);
|
|
expect(bundle).toContain("(url) => import(url)");
|
|
});
|
|
|
|
it("contains no module identity and no module API prefix", () => {
|
|
// The shell learns every module id, artifact URL, and API root from the inventory at run time.
|
|
// Any of these appearing in the bundle would mean it had been baked in instead.
|
|
expect(bundle).not.toContain("/api/v1/modules");
|
|
expect(bundle).not.toContain("/api/v1/module-ui/");
|
|
expect(bundle).not.toContain("ekklesia.module.json");
|
|
expect(bundle).not.toContain("entrypoints");
|
|
});
|
|
|
|
it("spells only the platform paths it genuinely owns", () => {
|
|
// The platform's own endpoints — never a module's. Everything module-specific arrives at run
|
|
// time from the navigation the server resolved.
|
|
expect(bundle).toContain('"/api/v1/principal"');
|
|
expect(bundle).toContain('"/api/v1/dashboard/navigation"');
|
|
// The shell reads the navigation view, which already contains the inventory; it never calls
|
|
// the host-neutral inventory endpoint itself.
|
|
expect(bundle).not.toContain('"/api/v1/module-ui"');
|
|
});
|
|
|
|
it("bakes in no group, label, or placement", () => {
|
|
// Grouping is server-resolved configuration. A group id or label appearing in the bundle would
|
|
// mean the shell had an opinion about an arrangement it must only render.
|
|
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
|
|
// bundled source and would make this assertion meaningless rather than strict.
|
|
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
|
|
expect(bundle.toLowerCase(), term).not.toContain(term);
|
|
}
|
|
});
|
|
|
|
it("uses neither eval nor a blob URL to load module code", () => {
|
|
expect(bundle).not.toContain("new Function(");
|
|
expect(bundle).not.toMatch(/\beval\s*\(/u);
|
|
expect(bundle).not.toContain("createObjectURL");
|
|
});
|
|
});
|