diff --git a/scripts/__tests__/migrate-brain.spec.ts b/scripts/__tests__/migrate-brain.spec.ts new file mode 100644 index 0000000..49526d7 --- /dev/null +++ b/scripts/__tests__/migrate-brain.spec.ts @@ -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; + 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; +} + +interface LoaderOptions { + argv?: string[]; + homeDirectory?: string; + readdirImpl?: (directoryPath: string, options: unknown) => Promise; + readFileImpl?: (filePath: string, encoding: string) => Promise; +} + +interface LoaderResult { + internals: ScriptInternals; + readdirMock: ReturnType; + readFileMock: ReturnType; + prismaClientConstructor: ReturnType; +} + +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 => [])); + const readFileMock = vi.fn(options.readFileImpl ?? (async (): Promise => "{}")); + + 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 }; + + 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 => [ + 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 => [makeDirent("core.json", true)]; + const readFileImpl = async (filePath: string): Promise => { + 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 => []; + + 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."); + }); +});