test(scripts): add migrate-brain unit tests (MS21-TEST-003) #566

Merged
jason.woltje merged 1 commits from test/ms21-migration-tests into main 2026-02-28 19:54:56 +00:00

View File

@@ -0,0 +1,429 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { Script, createContext } from "node:vm";
import { describe, expect, it, vi, afterEach } from "vitest";
import { ModuleKind, ScriptTarget, transpileModule } from "typescript";
interface CliOptions {
brainPath: string;
workspaceId: string;
userId: string;
apply: boolean;
}
interface MockDirent {
name: string;
isFile: () => boolean;
}
interface ScriptInternals {
parseCliArgs: (args: string[]) => CliOptions;
mapTaskStatus: (rawStatus: string | null) => { status: string; issue: string | null };
mapTaskPriority: (rawPriority: string | null) => { priority: string; issue: string | null };
mapProjectStatus: (rawStatus: string | null) => { status: string; issue: string | null };
normalizeDomain: (rawDomain: string | null | undefined) => string | null;
listJsonFiles: (directoryPath: string) => Promise<string[]>;
parseTaskFile: (
rawFile: unknown,
sourceFile: string,
parseIssues: string[]
) => {
version: string;
domain: string;
sourceFile: string;
tasks: Array<{ id: string; title: string }>;
} | null;
parseProjectFile: (
rawFile: unknown,
sourceFile: string,
parseIssues: string[]
) => {
version: string;
sourceFile: string;
project: { id: string; name: string };
} | null;
loadTaskFiles: (
taskDirectory: string,
parseIssues: string[],
rootPath: string
) => Promise<
Array<{
version: string;
domain: string;
sourceFile: string;
tasks: Array<{ id: string; title: string }>;
}>
>;
main: () => Promise<void>;
}
interface LoaderOptions {
argv?: string[];
homeDirectory?: string;
readdirImpl?: (directoryPath: string, options: unknown) => Promise<MockDirent[]>;
readFileImpl?: (filePath: string, encoding: string) => Promise<string>;
}
interface LoaderResult {
internals: ScriptInternals;
readdirMock: ReturnType<typeof vi.fn>;
readFileMock: ReturnType<typeof vi.fn>;
prismaClientConstructor: ReturnType<typeof vi.fn>;
}
const testDir = path.dirname(fileURLToPath(import.meta.url));
const scriptPath = path.resolve(testDir, "..", "migrate-brain.ts");
const nativeRequire = createRequire(import.meta.url);
const makeDirent = (name: string, isFile: boolean): MockDirent => ({
name,
isFile: () => isFile,
});
function buildInstrumentedSource(source: string): string {
const invocationMarker = "main().catch((error: unknown) => {";
const markerIndex = source.lastIndexOf(invocationMarker);
if (markerIndex < 0) {
throw new Error("Could not find main invocation in migrate-brain.ts");
}
const sourceWithoutMain = source.slice(0, markerIndex);
return `${sourceWithoutMain}
module.exports.__test = {
parseCliArgs,
mapTaskStatus,
mapTaskPriority,
mapProjectStatus,
normalizeDomain,
listJsonFiles,
parseTaskFile,
parseProjectFile,
loadTaskFiles,
main,
};
`;
}
function loadMigrateBrainModule(options: LoaderOptions = {}): LoaderResult {
const source = readFileSync(scriptPath, "utf8");
const instrumentedSource = buildInstrumentedSource(source);
const transpiled = transpileModule(instrumentedSource, {
compilerOptions: {
module: ModuleKind.CommonJS,
target: ScriptTarget.ES2022,
esModuleInterop: true,
},
fileName: scriptPath,
});
const readdirMock = vi.fn(options.readdirImpl ?? (async (): Promise<MockDirent[]> => []));
const readFileMock = vi.fn(options.readFileImpl ?? (async (): Promise<string> => "{}"));
const prismaClientConstructor = vi.fn(() => ({
$disconnect: vi.fn(async () => undefined),
}));
const processMock = Object.create(process) as NodeJS.Process;
processMock.argv = options.argv ?? [
"node",
scriptPath,
"--workspace-id",
"workspace-1",
"--user-id",
"user-1",
];
const prismaClientModule = {
ActivityAction: { CREATED: "CREATED" },
EntityType: { DOMAIN: "DOMAIN", PROJECT: "PROJECT", TASK: "TASK" },
Prisma: {},
PrismaClient: prismaClientConstructor,
ProjectStatus: {
ACTIVE: "ACTIVE",
PLANNING: "PLANNING",
PAUSED: "PAUSED",
ARCHIVED: "ARCHIVED",
},
TaskPriority: {
HIGH: "HIGH",
MEDIUM: "MEDIUM",
LOW: "LOW",
},
TaskStatus: {
COMPLETED: "COMPLETED",
IN_PROGRESS: "IN_PROGRESS",
NOT_STARTED: "NOT_STARTED",
PAUSED: "PAUSED",
ARCHIVED: "ARCHIVED",
},
};
const module = { exports: {} as Record<string, unknown> };
const requireFromScript = (specifier: string): unknown => {
if (specifier === "node:fs/promises") {
return {
readdir: readdirMock,
readFile: readFileMock,
};
}
if (specifier === "node:os") {
return {
homedir: () => options.homeDirectory ?? "/home/tester",
};
}
if (specifier === "node:process") {
return processMock;
}
if (specifier === "../apps/api/node_modules/@prisma/client") {
return prismaClientModule;
}
return nativeRequire(specifier);
};
const context = createContext({
module,
exports: module.exports,
require: requireFromScript,
__dirname: path.dirname(scriptPath),
__filename: scriptPath,
console,
process: processMock,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Buffer,
});
new Script(transpiled.outputText, { filename: scriptPath }).runInContext(context);
const exported = module.exports as { __test?: ScriptInternals };
if (!exported.__test) {
throw new Error("Failed to expose migrate-brain internals for tests");
}
return {
internals: exported.__test,
readdirMock,
readFileMock,
prismaClientConstructor,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("migrate-brain mapping helpers", () => {
it("maps statuses and priorities, including unknown-value fallbacks", () => {
const { internals } = loadMigrateBrainModule();
expect(internals.mapTaskStatus("done")).toEqual({
status: "COMPLETED",
issue: null,
});
expect(internals.mapTaskStatus("mystery")).toEqual({
status: "NOT_STARTED",
issue: 'Unknown task status "mystery" mapped to NOT_STARTED',
});
expect(internals.mapTaskPriority("critical")).toEqual({
priority: "HIGH",
issue: null,
});
expect(internals.mapTaskPriority(null)).toEqual({
priority: "MEDIUM",
issue: 'Unknown task priority "null" mapped to MEDIUM',
});
expect(internals.mapProjectStatus("in-progress")).toEqual({
status: "ACTIVE",
issue: null,
});
expect(internals.mapProjectStatus("untracked")).toEqual({
status: "PLANNING",
issue: 'Unknown project status "untracked" mapped to PLANNING',
});
});
it("normalizes domain strings into lowercase slugs", () => {
const { internals } = loadMigrateBrainModule();
expect(internals.normalizeDomain(" Platform Core ")).toBe("platform-core");
expect(internals.normalizeDomain("###")).toBeNull();
expect(internals.normalizeDomain(undefined)).toBeNull();
});
});
describe("migrate-brain CLI parsing", () => {
it("parses required arguments and default brain path", () => {
const { internals } = loadMigrateBrainModule({ homeDirectory: "/opt/home" });
const parsed = internals.parseCliArgs([
"--workspace-id",
"workspace-abc",
"--user-id",
"user-xyz",
]);
expect(parsed).toEqual({
brainPath: path.resolve("/opt/home/src/jarvis-brain"),
workspaceId: "workspace-abc",
userId: "user-xyz",
apply: false,
});
});
it("supports inline flags and apply mode", () => {
const { internals } = loadMigrateBrainModule({ homeDirectory: "/opt/home" });
const parsed = internals.parseCliArgs([
"--brain-path=~/custom-brain",
"--workspace-id=workspace-1",
"--user-id=user-1",
"--apply",
]);
expect(parsed).toEqual({
brainPath: path.resolve("/opt/home/custom-brain"),
workspaceId: "workspace-1",
userId: "user-1",
apply: true,
});
});
it("throws on missing required flags and unknown flags", () => {
const { internals } = loadMigrateBrainModule();
expect(() => internals.parseCliArgs(["--workspace-id", "workspace-1"])).toThrowError(
"Both --workspace-id and --user-id are required"
);
expect(() =>
internals.parseCliArgs(["--workspace-id", "workspace-1", "--user-id", "user-1", "--nope"])
).toThrowError("Unknown flag: --nope");
});
});
describe("migrate-brain file discovery", () => {
it("returns only .json files in sorted order", async () => {
const readdirImpl = async (): Promise<MockDirent[]> => [
makeDirent("z.json", true),
makeDirent("notes.md", true),
makeDirent("nested", false),
makeDirent("a.json", true),
];
const { internals, readdirMock } = loadMigrateBrainModule({ readdirImpl });
const files = await internals.listJsonFiles("/tmp/brain/data/tasks");
expect(files).toEqual([
path.join("/tmp/brain/data/tasks", "a.json"),
path.join("/tmp/brain/data/tasks", "z.json"),
]);
expect(readdirMock).toHaveBeenCalledWith("/tmp/brain/data/tasks", {
withFileTypes: true,
});
});
});
describe("migrate-brain parsing and validation", () => {
it("loads task files and tracks validation issues for invalid task records", async () => {
const taskDirectory = "/tmp/brain/data/tasks";
const taskFilePath = path.join(taskDirectory, "core.json");
const readdirImpl = async (): Promise<MockDirent[]> => [makeDirent("core.json", true)];
const readFileImpl = async (filePath: string): Promise<string> => {
if (filePath === taskFilePath) {
return JSON.stringify({
version: "1",
domain: "core",
tasks: [{ id: "TASK-1", title: "Valid" }, { id: "TASK-2" }],
});
}
throw new Error(`Unexpected file read: ${filePath}`);
};
const { internals, readFileMock } = loadMigrateBrainModule({
readdirImpl,
readFileImpl,
});
const parseIssues: string[] = [];
const loaded = await internals.loadTaskFiles(taskDirectory, parseIssues, "/tmp/brain");
expect(loaded).toHaveLength(1);
expect(loaded[0]).toMatchObject({
version: "1",
domain: "core",
sourceFile: path.join("data", "tasks", "core.json"),
});
expect(loaded[0].tasks).toHaveLength(1);
expect(parseIssues).toContain(
'data/tasks/core.json task[1]: required field "title" missing or invalid'
);
expect(readFileMock).toHaveBeenCalledWith(taskFilePath, "utf8");
});
it("rejects invalid project payloads", () => {
const { internals } = loadMigrateBrainModule();
const parseIssues: string[] = [];
const parsed = internals.parseProjectFile(
{
version: "1",
project: {
name: "Project without ID",
},
},
"data/projects/project.json",
parseIssues
);
expect(parsed).toBeNull();
expect(parseIssues).toContain(
'data/projects/project.json project: required field "id" missing or invalid'
);
});
});
describe("migrate-brain dry-run behavior", () => {
it("does not instantiate Prisma client when --apply is omitted", async () => {
const brainPath = "/tmp/brain";
const readdirImpl = async (): Promise<MockDirent[]> => [];
const { internals, prismaClientConstructor, readdirMock } = loadMigrateBrainModule({
argv: [
"node",
scriptPath,
"--brain-path",
brainPath,
"--workspace-id",
"workspace-1",
"--user-id",
"user-1",
],
readdirImpl,
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await internals.main();
expect(prismaClientConstructor).not.toHaveBeenCalled();
expect(readdirMock).toHaveBeenCalledWith(path.join(brainPath, "data", "tasks"), {
withFileTypes: true,
});
expect(readdirMock).toHaveBeenCalledWith(path.join(brainPath, "data", "projects"), {
withFileTypes: true,
});
expect(logSpy).toHaveBeenCalledWith("Dry-run complete. Re-run with --apply to write records.");
});
});