From f122e910fc45f77b3b4415afd39d011b66f04fd1 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 7 Oct 2026 09:07:59 -0500 Subject: [PATCH] test: generate the external module fixture instead of copying ignored build output The test proving that migrations of a module installed outside the repository are discovered, while Core's come from the platform, copied templates/module including dist/. dist/ is gitignored and never committed. CI runs `pnpm test` before any build of the template, where only the template's own UI test may have written dist/ui, dist/display, and dist/contributions, so discovery failed with MODULE_ENTRYPOINT_MISSING for ./dist/server/index.js and ./dist/schema/index.js (and, had the UI test not run yet, the copy itself would have failed). Locally a previous `pnpm build` had left dist/ complete. The test now generates a minimal valid installed module in a temporary directory with createMigrationModule, moved unchanged from the migration discovery test into core/tests/support so both share it. It depends on no build output, ignored file, or node_modules. It also asserts more precisely: the module directory is outside the repository, Core's migrations match the platform's own core/drizzle inventory by identity and checksum, and the module's migration matches the SQL that was installed. Co-Authored-By: Claude Opus 5.5 --- core/tests/module-migration-discovery.test.ts | 166 +---------------- core/tests/modules-directory-commands.test.ts | 47 ++--- .../tests/support/migration-module-fixture.ts | 171 ++++++++++++++++++ 3 files changed, 203 insertions(+), 181 deletions(-) create mode 100644 core/tests/support/migration-module-fixture.ts diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 1884706..a48529e 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -10,34 +10,15 @@ import { ModuleMigrationDiscoveryError, type ModuleMigrationDiscoveryIssueCode, } from "../src/modules/index.js"; +import { + createMigrationModule, + manifestFor, + originSnapshotId, + snapshotId, +} from "./support/migration-module-fixture.js"; -const originSnapshotId = "00000000-0000-0000-0000-000000000000"; const temporaryDirectories: string[] = []; -interface MigrationFixture { - readonly breakpoints?: boolean; - readonly destructive?: - | { readonly mode: "none" } - | { readonly mode: "approval-required"; readonly reason: string }; - readonly snapshotId?: string; - readonly sql: string | Uint8Array; - readonly tag: string; - readonly timestamp?: number; -} - -interface MigrationModuleFixture { - readonly dependencies?: Record; - readonly manifest?: Record; - readonly migrations?: readonly MigrationFixture[] | false; - readonly schemaSource?: string; - readonly serverSource?: string; -} - -interface CreatedMigrationModule { - readonly migrationDirectory?: string; - readonly moduleDirectory: string; -} - afterEach(async () => { await Promise.all( temporaryDirectories @@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise { return directory; } -function snapshotId(index: number): string { - return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; -} - -function manifestFor( - id: string, - options: { - readonly dependencies?: Record; - readonly migrations?: boolean; - readonly schema?: boolean; - } = {}, -): Record { - return { - manifestVersion: 7, - id, - name: `${id} module`, - version: "0.1.0", - compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, - ...(options.dependencies ? { dependencies: options.dependencies } : {}), - capabilities: [], - permissions: [], - entrypoints: { - server: "./dist/server/index.js", - ...(options.schema ? { schema: "./dist/schema/index.js" } : {}), - ...(options.migrations === false ? {} : { migrations: "./drizzle" }), - }, - }; -} - -function defaultMigrations(id: string): readonly MigrationFixture[] { - return [ - { - sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`, - tag: "0000_initial", - }, - ]; -} - -async function createMigrationModule( - modulesDirectory: string, - id: string, - fixture: MigrationModuleFixture = {}, -): Promise { - const migrations = - fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id)); - const moduleDirectory = path.join(modulesDirectory, id); - const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined; - await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); - if (fixture.schemaSource !== undefined) { - await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true }); - } - if (migrationDirectory) { - await mkdir(path.join(migrationDirectory, "meta"), { recursive: true }); - } - - await Promise.all([ - writeFile( - path.join(moduleDirectory, "package.json"), - JSON.stringify({ - name: `@test/module-${id}`, - version: "0.1.0", - type: "module", - ekklesia: { layer: "module" }, - }), - ), - writeFile( - path.join(moduleDirectory, "ekklesia.module.json"), - JSON.stringify( - fixture.manifest ?? - manifestFor(id, { - ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), - migrations: migrations !== undefined, - schema: fixture.schemaSource !== undefined, - }), - ), - ), - writeFile( - path.join(moduleDirectory, "dist/server/index.js"), - fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, - ), - ...(fixture.schemaSource === undefined - ? [] - : [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]), - ]); - - if (!migrationDirectory || !migrations) { - return { moduleDirectory }; - } - - const journalEntries = migrations.map((migration, index) => ({ - idx: index, - version: "7", - when: migration.timestamp ?? 1_700_000_000_000 + index, - tag: migration.tag, - breakpoints: migration.breakpoints ?? true, - })); - const policyEntries = migrations.map((migration) => ({ - tag: migration.tag, - destructive: migration.destructive ?? { mode: "none" }, - })); - await Promise.all([ - writeFile( - path.join(migrationDirectory, "meta/_journal.json"), - JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }), - ), - writeFile( - path.join(migrationDirectory, "meta/_ekklesia.json"), - JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }), - ), - ...migrations.flatMap((migration, index) => { - const idForSnapshot = migration.snapshotId ?? snapshotId(index); - const previousId = - index === 0 - ? originSnapshotId - : (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1)); - return [ - writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql), - writeFile( - path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`), - JSON.stringify({ - version: "7", - dialect: "postgresql", - id: idForSnapshot, - prevId: previousId, - }), - ), - ]; - }), - ]); - - return { migrationDirectory, moduleDirectory }; -} - function discoveryOptions(modulesDirectory: string) { return { moduleSdkVersion: "0.1.0", diff --git a/core/tests/modules-directory-commands.test.ts b/core/tests/modules-directory-commands.test.ts index 5b80cb8..81343fe 100644 --- a/core/tests/modules-directory-commands.test.ts +++ b/core/tests/modules-directory-commands.test.ts @@ -1,14 +1,16 @@ -import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js"; import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js"; +import { discoverCoreMigrations } from "../src/migrations/index.js"; import { ModuleDiscoveryError } from "../src/modules/index.js"; +import { createMigrationModule } from "./support/migration-module-fixture.js"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); -const templateModule = path.join(repositoryRoot, "templates", "module"); const temporaryDirectories: string[] = []; afterEach(async () => { @@ -23,16 +25,6 @@ async function externalModulesDirectory(): Promise { return directory; } -/** Installs the module template's migration-bearing package files as module `example`. Migration - * discovery imports nothing, so the template's manifest, package metadata, and migrations are all - * it reads; the compiled artifacts discovery checks for are copied too when they exist. */ -async function installTemplate(modulesDirectory: string): Promise { - const target = path.join(modulesDirectory, "example"); - for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) { - await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true }); - } -} - describe("operator commands select the modules directory through host configuration", () => { it("defaults to the repository's own modules directory", () => { expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({ @@ -62,23 +54,34 @@ describe("operator commands select the modules directory through host configurat }); it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => { + // A minimal installed module generated from nothing in the repository, so this test never + // depends on build output or ignored files: a clean checkout runs it before anything is built. const modulesDirectory = await externalModulesDirectory(); - await installTemplate(modulesDirectory); + const installed = await createMigrationModule(modulesDirectory, "example"); + expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true); const aggregate = await discoverRepositoryMigrations(repositoryRoot, { EKKLESIA_MODULES_DIR: modulesDirectory, }); - const sources = aggregate.modules.map((module) => module.moduleId); - expect(sources[0]).toBe("core"); - expect(sources).toContain("example"); - expect( - aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"), - ).toBe(true); - const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) => - name.endsWith(".sql"), + expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]); + + // Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`. + const platformCore = await discoverCoreMigrations({ + coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"), + }); + const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) => + migrations.map((migration) => `${migration.identity} ${migration.checksum}`); + expect(platformCore.length).toBeGreaterThan(0); + expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore)); + + // The module's migration is the one installed outside the repository, byte for byte. + const installedSql = await readFile( + path.join(installed.migrationDirectory ?? "", "0000_initial.sql"), ); - expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length); + expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([ + `example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`, + ]); }); it("fails closed when the configured directory does not exist", async () => { diff --git a/core/tests/support/migration-module-fixture.ts b/core/tests/support/migration-module-fixture.ts new file mode 100644 index 0000000..3989a87 --- /dev/null +++ b/core/tests/support/migration-module-fixture.ts @@ -0,0 +1,171 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * A minimal, valid installed module written into a directory the caller owns: `package.json`, + * `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless + * disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain, + * and SQL) that passes Core's migration discovery. + * + * Everything is generated here, from nothing in the repository: no build output, no ignored file, + * and no `node_modules`. Migration discovery imports no module code, so the stub is never run. + */ + +export const originSnapshotId = "00000000-0000-0000-0000-000000000000"; + +export interface MigrationFixture { + readonly breakpoints?: boolean; + readonly destructive?: + | { readonly mode: "none" } + | { readonly mode: "approval-required"; readonly reason: string }; + readonly snapshotId?: string; + readonly sql: string | Uint8Array; + readonly tag: string; + readonly timestamp?: number; +} + +export interface MigrationModuleFixture { + readonly dependencies?: Record; + readonly manifest?: Record; + readonly migrations?: readonly MigrationFixture[] | false; + readonly schemaSource?: string; + readonly serverSource?: string; +} + +export interface CreatedMigrationModule { + readonly migrationDirectory?: string; + readonly moduleDirectory: string; +} + +export function snapshotId(index: number): string { + return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; +} + +export function manifestFor( + id: string, + options: { + readonly dependencies?: Record; + readonly migrations?: boolean; + readonly schema?: boolean; + } = {}, +): Record { + return { + manifestVersion: 7, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + ...(options.dependencies ? { dependencies: options.dependencies } : {}), + capabilities: [], + permissions: [], + entrypoints: { + server: "./dist/server/index.js", + ...(options.schema ? { schema: "./dist/schema/index.js" } : {}), + ...(options.migrations === false ? {} : { migrations: "./drizzle" }), + }, + }; +} + +export function defaultMigrations(id: string): readonly MigrationFixture[] { + return [ + { + sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`, + tag: "0000_initial", + }, + ]; +} + +export async function createMigrationModule( + modulesDirectory: string, + id: string, + fixture: MigrationModuleFixture = {}, +): Promise { + const migrations = + fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id)); + const moduleDirectory = path.join(modulesDirectory, id); + const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined; + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + if (fixture.schemaSource !== undefined) { + await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true }); + } + if (migrationDirectory) { + await mkdir(path.join(migrationDirectory, "meta"), { recursive: true }); + } + + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + name: `@test/module-${id}`, + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify( + fixture.manifest ?? + manifestFor(id, { + ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), + migrations: migrations !== undefined, + schema: fixture.schemaSource !== undefined, + }), + ), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, + ), + ...(fixture.schemaSource === undefined + ? [] + : [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]), + ]); + + if (!migrationDirectory || !migrations) { + return { moduleDirectory }; + } + + const journalEntries = migrations.map((migration, index) => ({ + idx: index, + version: "7", + when: migration.timestamp ?? 1_700_000_000_000 + index, + tag: migration.tag, + breakpoints: migration.breakpoints ?? true, + })); + const policyEntries = migrations.map((migration) => ({ + tag: migration.tag, + destructive: migration.destructive ?? { mode: "none" }, + })); + await Promise.all([ + writeFile( + path.join(migrationDirectory, "meta/_journal.json"), + JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }), + ), + writeFile( + path.join(migrationDirectory, "meta/_ekklesia.json"), + JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }), + ), + ...migrations.flatMap((migration, index) => { + const idForSnapshot = migration.snapshotId ?? snapshotId(index); + const previousId = + index === 0 + ? originSnapshotId + : (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1)); + return [ + writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql), + writeFile( + path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`), + JSON.stringify({ + version: "7", + dialect: "postgresql", + id: idForSnapshot, + prevId: previousId, + }), + ), + ]; + }), + ]); + + return { migrationDirectory, moduleDirectory }; +}