test(scripts): add migrate-brain unit tests (MS21-TEST-003) (#566)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #566.
This commit is contained in:
429
scripts/__tests__/migrate-brain.spec.ts
Normal file
429
scripts/__tests__/migrate-brain.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user