import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type * as db from "@rakazo/db"; import { SpaceLimitError } from "@rakazo/db"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; const createSpaceForMember = vi.hoisted(() => vi.fn()); const ensureMainConversation = vi.hoisted(() => vi.fn()); vi.mock("@rakazo/db", async (original) => ({ ...(await original()), createSpaceForMember, ensureMainConversation, })); const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } }; /** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */ function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) { const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = []; createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => { if (spaces.length >= spaceLimit) throw new SpaceLimitError(); const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null }; spaces.push(space); return { id: space.id, name: space.name }; }); const prisma = { space: { findMany: async () => spaces.filter((space) => space.workspacePath), update: async ({ where, data }: SpaceUpdate) => { const space = spaces.find((candidate) => candidate.id === where.id); if (space) space.workspacePath = data.workspacePath; }, }, } as unknown as db.PrismaClient; return { prisma, spaces }; } async function scanHomeWithOneFile(): Promise { const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); await mkdir(join(home, "Projects")); await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); return home; } async function scanHomeWithRepos(...repos: string[]): Promise { const home = await scanHomeWithOneFile(); for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true }); return home; } async function completed(scanId: string, userId: string) { return await vi.waitFor( async () => { const status = await getScanStatus(scanId, userId); if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); return status; }, { timeout: 10_000, interval: 50 }, ); } /** The emulated runtime that the shared test setup pins keeps the scan simulated. */ function allowHostScan() { vi.stubEnv("AGENT_RUNTIME", ""); vi.stubEnv("SANDBOX_PROVIDER", ""); } describe("onboarding scan", () => { afterEach(() => { vi.unstubAllEnvs(); createSpaceForMember.mockReset(); ensureMainConversation.mockReset(); }); it("hides a scan from every account except the one that started it", async () => { const { prisma } = fakeDatabase(); const { scanId } = await startOnboardingScan({ actor: member }, prisma); await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ status: expect.any(String), }); await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); }); it("never reads the host without a configured scan home", async () => { const { prisma } = fakeDatabase(); const { scanId } = await startOnboardingScan({ actor: owner }, prisma); const status = await completed(scanId, owner.userId); expect(status.status).toBe("complete"); expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); }); it("lets only the deployment owner discover the configured scan home", async () => { allowHostScan(); const scanHome = await scanHomeWithRepos("Projects/app"); const { prisma } = fakeDatabase(); const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma); const memberStatus = await completed(asMember.scanId, member.userId); expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); expect(createSpaceForMember).not.toHaveBeenCalled(); const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma); const ownerStatus = await completed(asOwner.scanId, owner.userId); expect(ownerStatus.status).toBe("complete"); expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); }); it("links each repository of the deployment owner to one project of its own", async () => { allowHostScan(); const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim"); const app = await realpath(join(scanHome, "Projects", "app")); const { prisma, spaces } = fakeDatabase(); const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); const firstStatus = await completed(first.scanId, owner.userId); expect(firstStatus.status).toBe("complete"); expect(firstStatus.result).toMatchObject({ localRepos: [{ name: "app" }], createdProjects: [{ spaceId: "space-1", name: "app", path: app }], }); expect(createSpaceForMember).toHaveBeenCalledWith(prisma, { currentSpaceId: owner.spaceId, userId: owner.userId, name: "app", }); expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]); // Each new project starts with its own main agent. expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1"); // Scanning again finds the linked project instead of creating a second one. const again = await startOnboardingScan({ actor: owner, scanHome }, prisma); const againStatus = await completed(again.scanId, owner.userId); expect(againStatus.result).toMatchObject({ createdProjects: [] }); expect(createSpaceForMember).toHaveBeenCalledTimes(1); }); it("joins a running scan instead of linking its repositories twice", async () => { allowHostScan(); const scanHome = await scanHomeWithRepos("Projects/app"); const { prisma, spaces } = fakeDatabase(); const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); const second = await startOnboardingScan({ actor: owner, scanHome }, prisma); expect(second.scanId).toBe(first.scanId); await completed(first.scanId, owner.userId); expect(spaces).toHaveLength(1); }); it("stops creating projects at the space limit and still completes", async () => { allowHostScan(); const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c"); const { prisma } = fakeDatabase({ spaceLimit: 1 }); const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma); const status = await completed(scanId, owner.userId); expect(status.status).toBe("complete"); expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] }); expect(createSpaceForMember).toHaveBeenCalledTimes(2); }); });