import { join } from "node:path"; import { beforeEach, describe, expect, test, vi } from "vitest"; // Hoisted stub so the vi.mock factory can reference it. const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn(), })); // Mock node:child_process so tests that exercise the subprocess fallback path // don't actually invoke npm. Default: subprocess fails (non-zero exit), so // tests focused on the walk-up strategy continue to expect null. vi.mock("node:child_process", () => ({ spawnSync: mockSpawnSync, default: { spawnSync: mockSpawnSync }, })); import { discoverGlobalNodeModulesRoot } from "../src/node-modules-discovery"; import { isPiInfrastructureRead } from "../src/path-utils"; // ── discoverGlobalNodeModulesRoot ────────────────────────────────────────── describe("discoverGlobalNodeModulesRoot", () => { beforeEach(() => { // Default: subprocess fails, so walk-up-focused tests see null for URLs // with no node_modules ancestor. mockSpawnSync.mockReset(); mockSpawnSync.mockReturnValue({ status: 1, stdout: "" }); }); test("returns the node_modules dir when the file is inside one", () => { const url = "file:///opt/homebrew/lib/node_modules/pi-quick-perms/dist/external-directory.js"; expect(discoverGlobalNodeModulesRoot(url)).toBe( "/opt/homebrew/lib/node_modules", ); }); test("returns node_modules for a deeply nested file", () => { const url = "file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-quick-perms/src/external-directory.js"; expect(discoverGlobalNodeModulesRoot(url)).toBe( "/home/user/.nvm/versions/node/v20/lib/node_modules", ); }); test("returns node_modules for a bun global install path", () => { const url = "file:///home/user/.bun/install/global/node_modules/pi-quick-perms/dist/external-directory.js"; expect(discoverGlobalNodeModulesRoot(url)).toBe( "/home/user/.bun/install/global/node_modules", ); }); test("returns the innermost (closest-to-file) node_modules ancestor", () => { // The walk-up algorithm stops at the first node_modules dir it encounters, // which is the innermost one when the file is inside a nested install. // In practice this never happens for a real global install — the extension // is always directly at /node_modules/pi-quick-perms/… const url = "file:///opt/lib/node_modules/some-pkg/node_modules/pi-quick-perms/dist/index.js"; expect(discoverGlobalNodeModulesRoot(url)).toBe( "/opt/lib/node_modules/some-pkg/node_modules", ); }); test("returns null when the file is not inside any node_modules directory", () => { const url = "file:///home/user/development/pi-quick-perms/dist/external-directory.js"; expect(discoverGlobalNodeModulesRoot(url)).toBeNull(); }); test("returns null for a root-level file", () => { const url = "file:///external-directory.js"; expect(discoverGlobalNodeModulesRoot(url)).toBeNull(); }); test("returns null for an invalid URL", () => { expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull(); }); test("works with the real import.meta.url of this extension (smoke test)", () => { // The extension IS installed inside a node_modules tree when running in CI // or global install. In a local dev checkout the result may be null — that's // the documented graceful-degradation path. const result = discoverGlobalNodeModulesRoot(); expect(result === null || result.endsWith("node_modules")).toBe(true); }); test("the discovered path includes the pi-quick-perms package directory", () => { const url = "file:///opt/homebrew/lib/node_modules/pi-quick-perms/dist/external-directory.js"; const root = discoverGlobalNodeModulesRoot(url); expect(root).not.toBeNull(); expect(join(root!, "pi-quick-perms")).toBe( "/opt/homebrew/lib/node_modules/pi-quick-perms", ); }); }); // ── isPiInfrastructureRead ───────────────────────────────────────────────── const INFRA_DIRS = [ "/home/user/.pi/agent", "/home/user/.pi/agent/git", "/opt/homebrew/lib/node_modules", ]; const CWD = "/home/user/project"; describe("isPiInfrastructureRead", () => { // ── read tools allowed for infra paths ────────────────────────────────── test("allows 'read' tool for a file inside agentDir", () => { expect( isPiInfrastructureRead( "read", "/home/user/.pi/agent/extensions/pi-quick-perms/config.json", INFRA_DIRS, CWD, ), ).toBe(true); }); test("allows 'find' tool for a path inside node_modules infra dir", () => { expect( isPiInfrastructureRead( "find", "/opt/homebrew/lib/node_modules/pi-ask-user/skills", INFRA_DIRS, CWD, ), ).toBe(true); }); test("allows 'grep' tool for a path inside agentDir/git", () => { expect( isPiInfrastructureRead( "grep", "/home/user/.pi/agent/git/some-package/README.md", INFRA_DIRS, CWD, ), ).toBe(true); }); test("allows 'ls' tool for a path inside node_modules infra dir", () => { expect( isPiInfrastructureRead( "ls", "/opt/homebrew/lib/node_modules/pi-quick-perms", INFRA_DIRS, CWD, ), ).toBe(true); }); // ── write tools never allowed even for infra paths ─────────────────────── test("blocks 'write' tool for a file inside agentDir", () => { expect( isPiInfrastructureRead( "write", "/home/user/.pi/agent/extensions/pi-quick-perms/config.json", INFRA_DIRS, CWD, ), ).toBe(false); }); test("blocks 'edit' tool for a file inside node_modules", () => { expect( isPiInfrastructureRead( "edit", "/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md", INFRA_DIRS, CWD, ), ).toBe(false); }); test("blocks 'bash' tool regardless of path", () => { expect( isPiInfrastructureRead( "bash", "/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md", INFRA_DIRS, CWD, ), ).toBe(false); }); // ── non-infra paths not allowed ────────────────────────────────────────── test("does not allow 'read' for a path outside all infra dirs", () => { expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe( false, ); }); test("does not allow 'read' for a path only partially matching an infra dir prefix", () => { // /home/user/.pi/agent-other should not match /home/user/.pi/agent expect( isPiInfrastructureRead( "read", "/home/user/.pi/agent-other/config.json", INFRA_DIRS, CWD, ), ).toBe(false); }); // ── project-local Pi packages (.pi/npm, .pi/git) ───────────────────────── test("allows 'read' for a path inside project-local .pi/npm/", () => { expect( isPiInfrastructureRead( "read", `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`, INFRA_DIRS, CWD, ), ).toBe(true); }); test("allows 'read' for a path inside project-local .pi/git/", () => { expect( isPiInfrastructureRead( "read", `${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`, INFRA_DIRS, CWD, ), ).toBe(true); }); test("blocks 'write' for a path inside project-local .pi/npm/", () => { expect( isPiInfrastructureRead( "write", `${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`, INFRA_DIRS, CWD, ), ).toBe(false); }); // ── empty / edge cases ─────────────────────────────────────────────────── test("returns false when infrastructureDirs is empty and path is not project-local", () => { expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false); }); test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => { // Project-local paths are checked separately from the dirs array. expect( isPiInfrastructureRead( "read", `${CWD}/.pi/npm/node_modules/x/SKILL.md`, [], CWD, ), ).toBe(true); }); });