CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
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 <[email protected]>
120 lines
5.2 KiB
TypeScript
120 lines
5.2 KiB
TypeScript
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 temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function externalModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
describe("operator commands select the modules directory through host configuration", () => {
|
|
it("defaults to the repository's own modules directory", () => {
|
|
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
|
modulesDirectory: "/opt/ekklesia/modules",
|
|
modulesDirectoryRequired: false,
|
|
});
|
|
});
|
|
|
|
it("honours EKKLESIA_MODULES_DIR", () => {
|
|
expect(
|
|
resolveCommandModulesDirectory(
|
|
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
|
"/opt/ekklesia",
|
|
),
|
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
|
});
|
|
|
|
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
|
expect(() =>
|
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
|
).toThrow(ModuleDiscoveryError);
|
|
try {
|
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
|
} catch (error) {
|
|
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
|
}
|
|
});
|
|
|
|
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();
|
|
const installed = await createMigrationModule(modulesDirectory, "example");
|
|
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
|
|
|
|
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
|
EKKLESIA_MODULES_DIR: modulesDirectory,
|
|
});
|
|
|
|
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(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 () => {
|
|
const parent = await externalModulesDirectory();
|
|
await expect(
|
|
discoverRepositoryMigrations(repositoryRoot, {
|
|
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
|
}),
|
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
|
});
|
|
});
|
|
|
|
describe("no source chooses a modules directory except the resolver", () => {
|
|
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
|
}
|
|
|
|
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
|
const files = [
|
|
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
|
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
|
];
|
|
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
|
for (const file of files) {
|
|
if (file === resolver) {
|
|
continue;
|
|
}
|
|
const text = await readFile(file, "utf8");
|
|
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
|
}
|
|
});
|
|
});
|