Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
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.");
|
|
});
|
|
});
|