Rename all four packages/pi-* directories to forge-native names, stripping the 'pi' identity and establishing forge's own: - packages/pi-coding-agent → packages/coding-agent - packages/pi-ai → packages/ai - packages/pi-agent-core → packages/agent-core - packages/pi-tui → packages/tui Package names updated: - @singularity-forge/pi-coding-agent → @singularity-forge/coding-agent - @singularity-forge/pi-ai → @singularity-forge/ai - @singularity-forge/pi-agent-core → @singularity-forge/agent-core - @singularity-forge/pi-tui → @singularity-forge/tui All import references, bare string references, path references, internal variable names (_bundledPi*), and dist files updated. @mariozechner/pi-* third-party compat aliases preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
/**
|
|
* Tests for ensureNodeModulesSymlink — covers symlink reconciliation for
|
|
* source installs (#3529) and pnpm-style merged node_modules (#3564).
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, test } from "vitest";
|
|
|
|
// --- Integration tests via initResources (source/monorepo path) ---
|
|
|
|
test("initResources creates node_modules symlink in agent dir", async (_t) => {
|
|
const { initResources } = await import("../resource-loader.ts");
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-symlink-"));
|
|
const fakeAgentDir = join(tmp, "agent");
|
|
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
initResources(fakeAgentDir);
|
|
|
|
const nodeModulesPath = join(fakeAgentDir, "node_modules");
|
|
// Use lstatSync instead of existsSync — existsSync follows the symlink and
|
|
// returns false for dangling symlinks (e.g. in worktrees without node_modules)
|
|
let stat: import("node:fs").Stats;
|
|
try {
|
|
stat = lstatSync(nodeModulesPath);
|
|
} catch {
|
|
assert.fail("node_modules symlink should exist after initResources");
|
|
}
|
|
assert.equal(
|
|
stat.isSymbolicLink(),
|
|
true,
|
|
"node_modules should be a symlink, not a real directory",
|
|
);
|
|
});
|
|
|
|
test("initResources replaces a real directory blocking node_modules with a symlink", async (_t) => {
|
|
const { initResources } = await import("../resource-loader.ts");
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-symlink-realdir-"));
|
|
const fakeAgentDir = join(tmp, "agent");
|
|
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
initResources(fakeAgentDir);
|
|
|
|
const nodeModulesPath = join(fakeAgentDir, "node_modules");
|
|
|
|
// Remove the symlink and replace with a real directory
|
|
rmSync(nodeModulesPath, { recursive: true, force: true });
|
|
mkdirSync(nodeModulesPath, { recursive: true });
|
|
|
|
const statBefore = lstatSync(nodeModulesPath);
|
|
assert.equal(
|
|
statBefore.isSymbolicLink(),
|
|
false,
|
|
"should be a real directory before fix",
|
|
);
|
|
assert.equal(
|
|
statBefore.isDirectory(),
|
|
true,
|
|
"should be a real directory before fix",
|
|
);
|
|
|
|
// Second call should replace the real directory with a symlink
|
|
initResources(fakeAgentDir);
|
|
|
|
const statAfter = lstatSync(nodeModulesPath);
|
|
assert.equal(
|
|
statAfter.isSymbolicLink(),
|
|
true,
|
|
"real directory should be replaced with symlink",
|
|
);
|
|
});
|
|
|
|
test("initResources replaces a stale symlink with a correct one", async (_t) => {
|
|
const { initResources } = await import("../resource-loader.ts");
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-symlink-stale-"));
|
|
const fakeAgentDir = join(tmp, "agent");
|
|
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
initResources(fakeAgentDir);
|
|
|
|
const nodeModulesPath = join(fakeAgentDir, "node_modules");
|
|
const correctTarget = readlinkSync(nodeModulesPath);
|
|
|
|
// Remove and replace with a stale symlink pointing to a non-existent path
|
|
unlinkSync(nodeModulesPath);
|
|
symlinkSync(
|
|
"/tmp/nonexistent-sf-node-modules-" + Date.now(),
|
|
nodeModulesPath,
|
|
);
|
|
|
|
const staleTarget = readlinkSync(nodeModulesPath);
|
|
assert.notEqual(
|
|
staleTarget,
|
|
correctTarget,
|
|
"stale symlink should point elsewhere",
|
|
);
|
|
|
|
// Second call should fix the stale symlink
|
|
initResources(fakeAgentDir);
|
|
|
|
const fixedTarget = readlinkSync(nodeModulesPath);
|
|
assert.equal(
|
|
fixedTarget,
|
|
correctTarget,
|
|
"stale symlink should be replaced with correct target",
|
|
);
|
|
});
|
|
|
|
test("initResources replaces symlink whose target was deleted", async (_t) => {
|
|
const { initResources } = await import("../resource-loader.ts");
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-symlink-missing-"));
|
|
const fakeAgentDir = join(tmp, "agent");
|
|
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
initResources(fakeAgentDir);
|
|
|
|
const nodeModulesPath = join(fakeAgentDir, "node_modules");
|
|
const correctTarget = readlinkSync(nodeModulesPath);
|
|
|
|
// Create a symlink that points to a path that doesn't exist
|
|
unlinkSync(nodeModulesPath);
|
|
const deadTarget = join(tmp, "old-install", "node_modules");
|
|
symlinkSync(deadTarget, nodeModulesPath);
|
|
|
|
// The symlink itself exists but its target doesn't
|
|
assert.equal(lstatSync(nodeModulesPath).isSymbolicLink(), true);
|
|
assert.equal(existsSync(deadTarget), false, "dead target should not exist");
|
|
|
|
initResources(fakeAgentDir);
|
|
|
|
const fixedTarget = readlinkSync(nodeModulesPath);
|
|
assert.equal(
|
|
fixedTarget,
|
|
correctTarget,
|
|
"broken symlink should be replaced with correct target",
|
|
);
|
|
});
|
|
|
|
// --- Unit tests for pnpm-style merged node_modules (#3564) ---
|
|
// These simulate the filesystem layout without going through initResources,
|
|
// since packageRoot is fixed at module load time.
|
|
|
|
test("pnpm layout: merged node_modules contains entries from both hoisted and internal", (_t) => {
|
|
// Simulate pnpm global layout:
|
|
// hoisted/node_modules/
|
|
// yaml/ ← external dep
|
|
// @sinclair/ ← external scoped dep
|
|
// sf-run/ ← package root
|
|
// node_modules/
|
|
// @singularity-forge/ ← workspace scope (NOT hoisted)
|
|
// @singularity-forge/ ← workspace scope (NOT hoisted)
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-pnpm-merge-"));
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const hoisted = join(tmp, "node_modules");
|
|
const pkgRoot = join(hoisted, "sf-run");
|
|
const internal = join(pkgRoot, "node_modules");
|
|
const agentNodeModules = join(tmp, "agent", "node_modules");
|
|
|
|
// Create hoisted entries (external deps)
|
|
mkdirSync(join(hoisted, "yaml"), { recursive: true });
|
|
mkdirSync(join(hoisted, "@sinclair", "typebox"), { recursive: true });
|
|
mkdirSync(join(hoisted, "@anthropic-ai", "sdk"), { recursive: true });
|
|
mkdirSync(pkgRoot, { recursive: true });
|
|
|
|
// Create internal entries (workspace packages)
|
|
mkdirSync(join(internal, "@sf", "ai"), { recursive: true });
|
|
mkdirSync(join(internal, "@sf", "coding-agent"), { recursive: true });
|
|
mkdirSync(join(internal, "@sf-build", "core"), { recursive: true });
|
|
|
|
// Create merged directory manually (simulating what reconcileMergedNodeModules does)
|
|
mkdirSync(agentNodeModules, { recursive: true });
|
|
|
|
// Link hoisted entries (skip sf-run itself and dotfiles)
|
|
for (const entry of readdirSync(hoisted, { withFileTypes: true })) {
|
|
if (entry.name === "sf-run" || entry.name.startsWith(".")) continue;
|
|
symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name));
|
|
}
|
|
|
|
// Overlay all non-dotfile entries from internal (these take precedence)
|
|
for (const entry of readdirSync(internal, { withFileTypes: true })) {
|
|
if (entry.name.startsWith(".")) continue;
|
|
const link = join(agentNodeModules, entry.name);
|
|
try {
|
|
lstatSync(link);
|
|
unlinkSync(link);
|
|
} catch {
|
|
/* didn't exist */
|
|
}
|
|
symlinkSync(join(internal, entry.name), link);
|
|
}
|
|
|
|
// Verify: external deps resolve through hoisted symlinks
|
|
assert.ok(existsSync(join(agentNodeModules, "yaml")), "yaml should resolve");
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@sinclair")),
|
|
"@sinclair should resolve",
|
|
);
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@anthropic-ai")),
|
|
"@anthropic-ai should resolve",
|
|
);
|
|
|
|
// Verify: workspace packages resolve through internal symlinks
|
|
assert.ok(existsSync(join(agentNodeModules, "@sf")), "@sf should resolve");
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@sf", "ai")),
|
|
"@singularity-forge/ai should resolve",
|
|
);
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@sf-build")),
|
|
"@sf-build should resolve",
|
|
);
|
|
|
|
// Verify: sf-run itself is NOT symlinked (it's the package root, not a dep)
|
|
assert.ok(
|
|
!existsSync(join(agentNodeModules, "sf-run")),
|
|
"sf-run should not be in merged dir",
|
|
);
|
|
|
|
// Verify: @sf points to internal, not hoisted (internal takes precedence)
|
|
const sfTarget = readlinkSync(join(agentNodeModules, "@sf"));
|
|
assert.equal(
|
|
sfTarget,
|
|
join(internal, "@sf"),
|
|
"@sf should point to internal node_modules",
|
|
);
|
|
});
|
|
|
|
test("pnpm layout: non-@sf internal deps (e.g. @anthropic-ai) are included in merged dir", (_t) => {
|
|
// Regression: PR #3564 narrowed the internal overlay to @sf* only,
|
|
// dropping optionalDependencies like @anthropic-ai/claude-agent-sdk
|
|
// that npm installs internally rather than hoisting.
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-pnpm-internal-optional-"));
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const hoisted = join(tmp, "node_modules");
|
|
const pkgRoot = join(hoisted, "sf-run");
|
|
const internal = join(pkgRoot, "node_modules");
|
|
const agentNodeModules = join(tmp, "agent", "node_modules");
|
|
|
|
// Hoisted: only external deps (no @anthropic-ai — it's internal-only)
|
|
mkdirSync(join(hoisted, "yaml"), { recursive: true });
|
|
mkdirSync(pkgRoot, { recursive: true });
|
|
|
|
// Internal: workspace packages + optional dep that wasn't hoisted
|
|
mkdirSync(join(internal, "@sf", "ai"), { recursive: true });
|
|
mkdirSync(join(internal, "@anthropic-ai", "claude-agent-sdk"), {
|
|
recursive: true,
|
|
});
|
|
|
|
mkdirSync(agentNodeModules, { recursive: true });
|
|
|
|
// Link hoisted entries
|
|
for (const entry of readdirSync(hoisted, { withFileTypes: true })) {
|
|
if (entry.name === "sf-run" || entry.name.startsWith(".")) continue;
|
|
symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name));
|
|
}
|
|
|
|
// Overlay all non-dotfile internal entries (the fixed logic)
|
|
for (const entry of readdirSync(internal, { withFileTypes: true })) {
|
|
if (entry.name.startsWith(".")) continue;
|
|
const link = join(agentNodeModules, entry.name);
|
|
try {
|
|
lstatSync(link);
|
|
unlinkSync(link);
|
|
} catch {
|
|
/* didn't exist */
|
|
}
|
|
symlinkSync(join(internal, entry.name), link);
|
|
}
|
|
|
|
// @anthropic-ai must be present — this is what broke in #3564
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@anthropic-ai")),
|
|
"@anthropic-ai should resolve from internal",
|
|
);
|
|
assert.ok(
|
|
existsSync(join(agentNodeModules, "@anthropic-ai", "claude-agent-sdk")),
|
|
"@anthropic-ai/claude-agent-sdk should resolve",
|
|
);
|
|
|
|
// @sf still resolves
|
|
assert.ok(existsSync(join(agentNodeModules, "@sf")), "@sf should resolve");
|
|
|
|
// Hoisted deps still resolve
|
|
assert.ok(existsSync(join(agentNodeModules, "yaml")), "yaml should resolve");
|
|
});
|
|
|
|
test("hasMissingWorkspaceScopes detects pnpm layout", (_t) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-pnpm-detect-"));
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const hoisted = join(tmp, "hoisted");
|
|
const internal = join(tmp, "internal");
|
|
|
|
// npm-style: @sf exists in both hoisted and internal
|
|
mkdirSync(join(hoisted, "@sf"), { recursive: true });
|
|
mkdirSync(join(internal, "@sf"), { recursive: true });
|
|
|
|
// Inline the detection logic for testing
|
|
const hasMissing = (h: string, i: string): boolean => {
|
|
if (!existsSync(i)) return false;
|
|
for (const entry of readdirSync(i, { withFileTypes: true })) {
|
|
if (
|
|
entry.isDirectory() &&
|
|
entry.name.startsWith("@sf") &&
|
|
!existsSync(join(h, entry.name))
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
assert.equal(
|
|
hasMissing(hoisted, internal),
|
|
false,
|
|
"npm-style: no missing scopes",
|
|
);
|
|
|
|
// pnpm-style: @sf-build only in internal
|
|
mkdirSync(join(internal, "@sf-build"), { recursive: true });
|
|
assert.equal(
|
|
hasMissing(hoisted, internal),
|
|
true,
|
|
"pnpm-style: @sf-build missing from hoisted",
|
|
);
|
|
});
|
|
|
|
test("merged node_modules marker uses fingerprint including directory entries", (_t) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "sf-pnpm-marker-"));
|
|
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
// Simulate two directories with known entries
|
|
const hoisted = join(tmp, "hoisted");
|
|
const internal = join(tmp, "internal");
|
|
mkdirSync(join(hoisted, "yaml"), { recursive: true });
|
|
mkdirSync(join(hoisted, "@sinclair"), { recursive: true });
|
|
mkdirSync(join(internal, "@sf"), { recursive: true });
|
|
|
|
// Build fingerprint the same way the production code does
|
|
const h = readdirSync(hoisted).sort().join(",");
|
|
const i = readdirSync(internal).sort().join(",");
|
|
const fakePackageRoot = "/usr/lib/node_modules/sf-run";
|
|
const fingerprint = `${fakePackageRoot}\n${h}\n${i}`;
|
|
|
|
const agentNodeModules = join(tmp, "agent", "node_modules");
|
|
mkdirSync(agentNodeModules, { recursive: true });
|
|
const marker = join(agentNodeModules, ".sf-merged");
|
|
writeFileSync(marker, fingerprint);
|
|
|
|
// Verify fingerprint contains all three components
|
|
const stored = readFileSync(marker, "utf-8").trim();
|
|
assert.ok(
|
|
stored.includes(fakePackageRoot),
|
|
"fingerprint includes packageRoot",
|
|
);
|
|
assert.ok(
|
|
stored.includes("@sinclair"),
|
|
"fingerprint includes hoisted entries",
|
|
);
|
|
assert.ok(stored.includes("@sf"), "fingerprint includes internal entries");
|
|
|
|
// Verify fingerprint changes when a new package is added
|
|
mkdirSync(join(hoisted, "new-package"), { recursive: true });
|
|
const h2 = readdirSync(hoisted).sort().join(",");
|
|
const fingerprint2 = `${fakePackageRoot}\n${h2}\n${i}`;
|
|
assert.notEqual(
|
|
fingerprint,
|
|
fingerprint2,
|
|
"fingerprint should change when deps change",
|
|
);
|
|
});
|
|
|
|
test("reconcileMergedNodeModules uses junction symlinks for Windows compatibility", () => {
|
|
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
const source = readFileSync(
|
|
join(testDir, "..", "resource-loader.ts"),
|
|
"utf-8",
|
|
);
|
|
|
|
assert.match(
|
|
source,
|
|
/symlinkSync\(\s*join\(hoisted,\s*entry\.name\),\s*join\(agentNodeModules,\s*entry\.name\),\s*['"]junction['"],?\s*\)/s,
|
|
"hoisted merged symlink must use 'junction'",
|
|
);
|
|
assert.match(
|
|
source,
|
|
/symlinkSync\(\s*join\(internal,\s*entry\.name\),\s*link,\s*['"]junction['"],?\s*\)/s,
|
|
"internal merged symlink must use 'junction'",
|
|
);
|
|
});
|