Files
Ekklesia/tests/boundaries.test.ts
T
ekklesiaandClaude Opus 5 07835b7b56 feat: give module UI real stylesheets, and every artifact its own root
ADR 0023 refused stylesheet imports for a good reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would
fetch it, and it would work in a local test and silently do nothing in a real
host. Inline style objects were the whole story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.

Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.

Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.

This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.

An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.

`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:21:14 -05:00

231 lines
11 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { checkBoundaries } from "../scripts/check-boundaries.js";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const fixtures = path.join(testDirectory, "fixtures", "boundaries");
describe("repository dependency boundaries", () => {
it("accepts public contracts below Core", async () => {
const result = await checkBoundaries(path.join(fixtures, "legal"));
expect(result.violations).toEqual([]);
});
it("accepts generated module composition only through the generated bootstrap boundary", async () => {
const result = await checkBoundaries(path.join(fixtures, "legal-generated-bootstrap"));
expect(result.violations).toEqual([]);
});
it("honors explicit pnpm workspace exclusions", async () => {
const result = await checkBoundaries(path.join(fixtures, "workspace-exclusion"));
expect(result.violations).toEqual([]);
});
it.each([
["core-imports-module", "core-is-feature-agnostic"],
["app-imports-module", "apps-are-composition-roots"],
["module-imports-core", "modules-use-public-platform-contracts"],
["module-imports-surface", "modules-use-public-platform-contracts"],
["package-boundary-direction", "modules-use-public-platform-contracts"],
["cross-module-import", "modules-do-not-import-other-modules"],
["module-schema-imports-module", "modules-do-not-import-other-modules"],
["surface-imports-module", "surfaces-use-public-contracts"],
["surface-imports-contracts", "surfaces-use-public-contracts"],
["cross-surface-import", "surfaces-are-independent"],
["builder-imports-module", "builder-composes-contracts"],
["contracts-import-core", "contracts-do-not-depend-upward"],
["sdk-imports-core", "sdks-do-not-depend-on-implementations"],
["shared-role-order", "sdks-do-not-depend-on-implementations"],
["package-without-exports", "workspace-imports-use-public-exports"],
["private-package-import", "workspace-imports-use-public-exports"],
["non-workspace-version", "internal-dependencies-use-workspace-protocol"],
["workspace-alias", "workspace-dependency-aliases-are-forbidden"],
["undeclared-workspace-dependency", "workspace-dependencies-are-declared"],
["duplicate-package-name", "workspace-package-names-are-unique"],
["unknown-shared-role", "shared-packages-declare-known-roles"],
["workspace-unsupported-pattern", "workspace-patterns-use-supported-syntax"],
["package-cycle", "no-circular-package-dependencies"],
["module-has-ui", "modules-do-not-own-ui"],
["module-ui-framework", "modules-do-not-own-ui"],
["module-ui-aliased-framework", "modules-do-not-own-ui"],
["module-ui-imports-core", "modules-use-public-platform-contracts"],
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
["module-imports-ui-host", "modules-use-public-platform-contracts"],
["module-imports-surface-sdk", "modules-use-public-platform-contracts"],
["design-package-depends-upward", "design-packages-depend-downward-only"],
["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"],
])("rejects %s", async (fixture, expectedRule) => {
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
});
it("rejects a misplaced stylesheet and case-insensitive view files inside a module, but not TSX", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
const uiViolations = result.violations.filter(
(violation) => violation.rule === "modules-do-not-own-ui",
);
expect(uiViolations).toHaveLength(2);
expect(uiViolations.map((violation) => violation.from)).toEqual(
expect.arrayContaining([
// A stylesheet outside a compiled browser source root is a file no build reads and no host
// loads — the undeclared sidecar ADR 0023 rejected, just relocated (ADR 0040).
"modules/calendar/src/calendar.css",
"modules/calendar/src/view.ASTRO",
]),
);
// Inside `src/ui/`, a plain stylesheet is legitimate source the canonical builder compiles into
// the declared artifact.
expect(uiViolations.map((violation) => violation.from)).not.toContain(
"modules/calendar/src/ui/styles.css",
);
// React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is
// legitimate source and must no longer be reported.
expect(uiViolations.map((violation) => violation.from)).not.toContain(
"modules/calendar/src/calendar.tsx",
);
});
it("rejects unsupported UI frameworks, their imports, and view formats inside a module", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-ui-framework"));
const uiViolations = result.violations.filter(
(violation) => violation.rule === "modules-do-not-own-ui",
);
expect(uiViolations).toHaveLength(3);
expect(uiViolations.map((violation) => violation.message)).toEqual(
expect.arrayContaining([
"Module package depends on UI framework preact.",
"Module source imports UI framework preact.",
]),
);
// The fixture also aliases `compat` to react, which is now the supported framework and is
// therefore no longer a violation.
expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat");
});
it("still rejects module UI source that reaches into Core internals", async () => {
// Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not
// change which layers a module may import: module UI is subject to the identical layer rules
// as module backend source.
const result = await checkBoundaries(path.join(fixtures, "module-ui-imports-core"));
expect(result.violations).toEqual([
{
from: "modules/announcements/src/ui/editor.tsx",
message: "module code may not import core code directly.",
rule: "modules-use-public-platform-contracts",
to: "core/src/index.ts",
},
]);
});
it("accepts the canonical module UI dependency set: React, the UI SDK, and the build tool", async () => {
// Mirrors what `templates/module` actually declares (ADR 0023). The build tool is a `tooling`
// shared package, a role modules were already permitted to depend on, so the module UI build
// pipeline required no boundary relaxation at all.
const result = await checkBoundaries(path.join(fixtures, "module-owns-react-ui"));
expect(result.violations).toEqual([]);
});
it("accepts a module consuming the approved design packages", async () => {
// ADR 0039 opens exactly two shared roles to module UI — `design-tokens` and `ui` — so two
// modules' screens can look like one product. Both are browser-safe by construction, so this
// adds no path from module UI to a backend type graph.
const result = await checkBoundaries(path.join(fixtures, "module-uses-design-packages"));
expect(result.violations).toEqual([]);
});
it("keeps every other shared role out of module packages", async () => {
// The relaxation above is a named allowlist, not "browser-safe shared packages are fine": a
// surface SDK, an API client, and an auth client all stay rejected.
const result = await checkBoundaries(path.join(fixtures, "module-imports-surface-sdk"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"modules-use-public-platform-contracts",
"modules-use-public-platform-contracts",
]);
});
it("keeps the design packages at the bottom of the presentation stack", async () => {
// Neither may reach an API client, an auth client, the contracts barrel, or an implementation
// layer: a design package that could would put all of it inside every module artifact that
// renders a button.
const result = await checkBoundaries(path.join(fixtures, "design-package-depends-upward"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
]);
});
it("keeps the host loader out of module packages", async () => {
// `@ekklesia/module-ui-host` is host infrastructure. A module that depended on it would bundle
// loader code into its own artifact and blur the author/host split (ADR 0024), so the
// `module-ui-host` role is deliberately absent from the roles a module may depend on.
const result = await checkBoundaries(path.join(fixtures, "module-imports-ui-host"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"modules-use-public-platform-contracts",
"modules-use-public-platform-contracts",
]);
});
it("still rejects a module reaching for a bundler plugin ecosystem", async () => {
// Module authors do not configure a bundler; `@ekklesia/module-ui-build` owns that entirely.
const result = await checkBoundaries(path.join(fixtures, "module-ui-bundler-plugin"));
expect(result.violations.map((violation) => violation.message)).toEqual([
"Module package depends on UI framework @vitejs/plugin-react.",
]);
});
it("rejects an unsupported framework aliased behind the supported one", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-ui-aliased-framework"));
expect(result.violations.map((violation) => violation.message)).toEqual([
"Module package depends on UI framework vue aliased as react.",
]);
});
it("requires workspace: ranges in every dependency field", async () => {
const result = await checkBoundaries(path.join(fixtures, "non-workspace-version"));
expect(
result.violations.filter(
(violation) => violation.rule === "internal-dependencies-use-workspace-protocol",
),
).toHaveLength(4);
});
it("resolves forbidden workspace aliases before applying layer rules", async () => {
const result = await checkBoundaries(path.join(fixtures, "workspace-alias"));
expect(
result.violations.filter(
(violation) => violation.rule === "workspace-dependency-aliases-are-forbidden",
),
).toHaveLength(2);
expect(result.violations.map((violation) => violation.rule)).toEqual(
expect.arrayContaining([
"workspace-dependency-aliases-are-forbidden",
"modules-use-public-platform-contracts",
]),
);
});
it("prevents contracts and SDKs from depending upward through shared packages", async () => {
const result = await checkBoundaries(path.join(fixtures, "shared-role-order"));
expect(result.violations.map((violation) => violation.rule)).toEqual(
expect.arrayContaining([
"contracts-do-not-depend-upward",
"sdks-do-not-depend-on-implementations",
]),
);
});
});