import { describe, expect, it, vi } from "vitest"; import type { PiPackageInfo } from "../shared/apiTypes.js"; import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js"; function fakeManager(packages: PiPackageInfo[] = []) { const listConfiguredPackages = vi.fn(() => packages); const installAndPersist = vi.fn(() => Promise.resolve()); const removeAndPersist = vi.fn(() => Promise.resolve(true)); const update = vi.fn(() => Promise.resolve()); const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update }; return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update }; } function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); return { promise, resolve, reject }; } describe("DefaultPiPackageService", () => { it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => { const fake = fakeManager([ { source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" }, { source: "../project-tools", scope: "project", filtered: true }, ]); const service = new DefaultPiPackageService(fake.manager); await expect(service.list()).resolves.toEqual({ packages: [ { source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" }, { source: "../project-tools", scope: "project", filtered: true }, ], }); }); it("installs through the default Pi package-manager behavior without a local option", async () => { const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]); const service = new DefaultPiPackageService(fake.manager); const response = await service.install("npm:@acme/tools"); expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools"); expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] }); }); it("removes user packages by default and project packages only when the known scope is supplied", async () => { const fake = fakeManager(); const service = new DefaultPiPackageService(fake.manager); await service.remove("npm:@acme/user-tools"); await service.remove("../project-tools", "project"); expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools"); expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true }); }); it("updates all configured packages or a single source", async () => { const fake = fakeManager(); const service = new DefaultPiPackageService(fake.manager); await service.update(); await service.update("npm:@acme/tools"); expect(fake.update).toHaveBeenNthCalledWith(1); expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools"); }); it("serializes package mutations in call order and lists after each mutation before starting the next", async () => { const firstMutation = deferred(); const events: string[] = []; let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }]; const listConfiguredPackages = vi.fn(() => { events.push(`list:${packages.map((configuredPackage) => configuredPackage.source).join(",")}`); return packages; }); const installAndPersist = vi.fn(async (source) => { events.push(`install:start:${source}`); await firstMutation.promise; packages = [{ source, scope: "user", filtered: false }]; events.push(`install:finish:${source}`); }); const removeAndPersist = vi.fn((source) => { events.push(`remove:start:${source}`); packages = []; events.push(`remove:finish:${source}`); return Promise.resolve(true); }); const update = vi.fn(() => Promise.resolve()); const flush = vi.fn>(() => { events.push("flush"); return Promise.resolve(); }); const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush }; const service = new DefaultPiPackageService(manager); const installPromise = service.install("npm:@acme/new-tools"); const removePromise = service.remove("npm:@acme/new-tools"); await Promise.resolve(); expect(installAndPersist).toHaveBeenCalledOnce(); expect(removeAndPersist).not.toHaveBeenCalled(); expect(events).toEqual(["install:start:npm:@acme/new-tools"]); firstMutation.resolve(); await expect(Promise.all([installPromise, removePromise])).resolves.toEqual([ { action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }] }, { action: "remove", source: "npm:@acme/new-tools", scope: "user", removed: true, packages: [] }, ]); expect(events).toEqual([ "install:start:npm:@acme/new-tools", "install:finish:npm:@acme/new-tools", "flush", "list:npm:@acme/new-tools", "remove:start:npm:@acme/new-tools", "remove:finish:npm:@acme/new-tools", "flush", "list:", ]); }); it("does not queue list requests behind an in-flight mutation", async () => { const mutation = deferred(); const events: string[] = []; let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }]; const listConfiguredPackages = vi.fn(() => { events.push("list"); return packages; }); const installAndPersist = vi.fn(async (source) => { events.push(`install:start:${source}`); await mutation.promise; packages = [{ source, scope: "user", filtered: false }]; events.push(`install:finish:${source}`); }); const removeAndPersist = vi.fn(() => Promise.resolve(true)); const update = vi.fn(() => Promise.resolve()); const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update }; const service = new DefaultPiPackageService(manager); const installPromise = service.install("npm:@acme/new-tools"); await Promise.resolve(); await expect(service.list()).resolves.toEqual({ packages: [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }] }); expect(events).toEqual(["install:start:npm:@acme/new-tools", "list"]); mutation.resolve(); await expect(installPromise).resolves.toEqual({ action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }], }); }); it("releases the mutation queue after a mutation fails", async () => { const failingMutation = deferred(); const events: string[] = []; const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false }]; const listConfiguredPackages = vi.fn(() => { events.push("list"); return packages; }); const installAndPersist = vi.fn(async (source) => { events.push(`install:start:${source}`); await failingMutation.promise; }); const removeAndPersist = vi.fn(() => Promise.resolve(true)); const update = vi.fn((source) => { events.push(`update:start:${source ?? "all"}`); return Promise.resolve(); }); const flush = vi.fn>(() => { events.push("flush"); return Promise.resolve(); }); const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush }; const service = new DefaultPiPackageService(manager); const installPromise = service.install("npm:@acme/fails"); const updatePromise = service.update("npm:@acme/tools"); await Promise.resolve(); expect(update).not.toHaveBeenCalled(); expect(events).toEqual(["install:start:npm:@acme/fails"]); failingMutation.reject(new Error("install failed")); await expect(installPromise).rejects.toThrow("install failed"); await expect(updatePromise).resolves.toEqual({ action: "update", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }], }); expect(events).toEqual([ "install:start:npm:@acme/fails", "update:start:npm:@acme/tools", "flush", "list", ]); }); });