Files
letta-code/src/tests/updater/auto-update.test.ts
2026-02-16 13:20:33 -08:00

402 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
buildInstallCommand,
buildLatestVersionUrl,
checkForUpdate,
detectPackageManager,
resolveUpdateInstallRegistryUrl,
resolveUpdatePackageName,
resolveUpdateRegistryBaseUrl,
} from "../../updater/auto-update";
describe("auto-update ENOTEMPTY handling", () => {
let testDir: string;
beforeEach(() => {
// Create a temp directory for testing
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "letta-test-"));
});
afterEach(() => {
// Clean up
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
describe("cleanupOrphanedDirs logic", () => {
test("removes directories starting with .letta-code-", async () => {
// Create test directories
const lettaAiDir = path.join(testDir, "lib/node_modules/@letta-ai");
fs.mkdirSync(lettaAiDir, { recursive: true });
// Create orphaned temp dirs (should be removed)
const orphan1 = path.join(lettaAiDir, ".letta-code-abc123");
const orphan2 = path.join(lettaAiDir, ".letta-code-xyz789");
fs.mkdirSync(orphan1);
fs.mkdirSync(orphan2);
// Create legitimate dirs (should NOT be removed)
const legitimate = path.join(lettaAiDir, "letta-code");
const otherPackage = path.join(lettaAiDir, "other-package");
fs.mkdirSync(legitimate);
fs.mkdirSync(otherPackage);
// Simulate cleanup logic
const { readdir, rm } = await import("node:fs/promises");
const entries = await readdir(lettaAiDir);
for (const entry of entries) {
if (entry.startsWith(".letta-code-")) {
await rm(path.join(lettaAiDir, entry), {
recursive: true,
force: true,
});
}
}
// Verify
expect(fs.existsSync(orphan1)).toBe(false);
expect(fs.existsSync(orphan2)).toBe(false);
expect(fs.existsSync(legitimate)).toBe(true);
expect(fs.existsSync(otherPackage)).toBe(true);
});
test("handles non-existent directory gracefully", async () => {
const nonExistent = path.join(testDir, "does/not/exist");
const { readdir } = await import("node:fs/promises");
// This should not throw
let error: NodeJS.ErrnoException | null = null;
try {
await readdir(nonExistent);
} catch (e) {
error = e as NodeJS.ErrnoException;
}
expect(error).not.toBeNull();
expect(error?.code).toBe("ENOENT");
});
test("handles empty directory", async () => {
const emptyDir = path.join(testDir, "empty");
fs.mkdirSync(emptyDir, { recursive: true });
const { readdir } = await import("node:fs/promises");
const entries = await readdir(emptyDir);
expect(entries).toEqual([]);
});
});
describe("ENOTEMPTY error detection", () => {
test("detects ENOTEMPTY in npm error message", () => {
const npmError = `npm error code ENOTEMPTY
npm error syscall rename
npm error path /Users/user/.npm-global/lib/node_modules/@letta-ai/letta-code
npm error dest /Users/user/.npm-global/lib/node_modules/@letta-ai/.letta-code-lnWEqMep
npm error errno -66
npm error ENOTEMPTY: directory not empty`;
expect(npmError.includes("ENOTEMPTY")).toBe(true);
});
test("detects ENOTEMPTY in error.message", () => {
const error = new Error(
"Command failed: npm install -g @letta-ai/letta-code@latest\nnpm error ENOTEMPTY: directory not empty",
);
expect(error.message.includes("ENOTEMPTY")).toBe(true);
});
test("does not false-positive on other errors", () => {
const networkError = "npm error ETIMEDOUT: network timeout";
const permissionError = "npm error EACCES: permission denied";
expect(networkError.includes("ENOTEMPTY")).toBe(false);
expect(permissionError.includes("ENOTEMPTY")).toBe(false);
});
});
describe("npm global path detection", () => {
test("path structure for cleanup is correct", () => {
// Test that the path we construct is valid
const globalPrefix = "/Users/test/.npm-global";
const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai");
// path.join normalizes separators for the current platform
expect(lettaAiDir).toContain("lib");
expect(lettaAiDir).toContain("node_modules");
expect(lettaAiDir).toContain("@letta-ai");
});
test("path structure works on Windows-style paths", () => {
// Windows uses different separators but path.join handles it
const globalPrefix = "C:\\Users\\test\\AppData\\Roaming\\npm";
const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai");
// path.join normalizes separators for the current platform
expect(lettaAiDir).toContain("lib");
expect(lettaAiDir).toContain("node_modules");
expect(lettaAiDir).toContain("@letta-ai");
});
});
});
describe("detectPackageManager", () => {
let originalArgv1: string;
let originalEnv: string | undefined;
beforeEach(() => {
originalArgv1 = process.argv[1] || "";
originalEnv = process.env.LETTA_PACKAGE_MANAGER;
delete process.env.LETTA_PACKAGE_MANAGER;
});
afterEach(() => {
process.argv[1] = originalArgv1;
if (originalEnv !== undefined) {
process.env.LETTA_PACKAGE_MANAGER = originalEnv;
} else {
delete process.env.LETTA_PACKAGE_MANAGER;
}
});
test("detects bun from path containing /.bun/", () => {
process.argv[1] =
"/Users/test/.bun/install/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("detects pnpm from path containing /.pnpm/", () => {
process.argv[1] =
"/Users/test/.local/share/pnpm/global/5/.pnpm/@letta-ai+letta-code@0.14.11/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("pnpm");
});
test("detects pnpm from path containing /pnpm/", () => {
process.argv[1] =
"/Users/test/.local/share/pnpm/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("pnpm");
});
test("defaults to npm for standard nvm path", () => {
process.argv[1] =
"/Users/test/.nvm/versions/node/v20.10.0/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
test("defaults to npm for standard npm global path", () => {
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
test("detects bun from Windows-style path", () => {
process.argv[1] =
"C:\\Users\\test\\.bun\\install\\global\\node_modules\\@letta-ai\\letta-code\\dist\\index.js";
expect(detectPackageManager()).toBe("bun");
});
test("LETTA_PACKAGE_MANAGER override returns specified PM", () => {
process.env.LETTA_PACKAGE_MANAGER = "bun";
// Even with an npm-style path, env var wins
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("invalid LETTA_PACKAGE_MANAGER falls back to path detection", () => {
process.env.LETTA_PACKAGE_MANAGER = "invalid";
process.argv[1] =
"/Users/test/.bun/install/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("invalid LETTA_PACKAGE_MANAGER with npm path falls back to npm", () => {
process.env.LETTA_PACKAGE_MANAGER = "yarn";
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
});
describe("update config resolution", () => {
test("resolveUpdatePackageName uses default when unset", () => {
expect(resolveUpdatePackageName({} as NodeJS.ProcessEnv)).toBe(
"@letta-ai/letta-code",
);
});
test("resolveUpdatePackageName uses valid override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "@scope/pkg",
} as NodeJS.ProcessEnv),
).toBe("@scope/pkg");
});
test("resolveUpdatePackageName ignores invalid override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "bad pkg",
} as NodeJS.ProcessEnv),
).toBe("@letta-ai/letta-code");
});
test("resolveUpdatePackageName ignores command-substitution-like override", () => {
expect(
resolveUpdatePackageName({
LETTA_UPDATE_PACKAGE_NAME: "@scope/pkg$(id)",
} as NodeJS.ProcessEnv),
).toBe("@letta-ai/letta-code");
});
test("resolveUpdateRegistryBaseUrl uses default when unset", () => {
expect(resolveUpdateRegistryBaseUrl({} as NodeJS.ProcessEnv)).toBe(
"https://registry.npmjs.org",
);
});
test("resolveUpdateRegistryBaseUrl uses valid override", () => {
expect(
resolveUpdateRegistryBaseUrl({
LETTA_UPDATE_REGISTRY_BASE_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toBe("http://localhost:4873");
});
test("resolveUpdateRegistryBaseUrl ignores invalid override", () => {
expect(
resolveUpdateRegistryBaseUrl({
LETTA_UPDATE_REGISTRY_BASE_URL: "javascript:alert(1)",
} as NodeJS.ProcessEnv),
).toBe("https://registry.npmjs.org");
});
test("resolveUpdateInstallRegistryUrl returns null when unset", () => {
expect(resolveUpdateInstallRegistryUrl({} as NodeJS.ProcessEnv)).toBeNull();
});
test("resolveUpdateInstallRegistryUrl returns valid override", () => {
expect(
resolveUpdateInstallRegistryUrl({
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toBe("http://localhost:4873");
});
test("resolveUpdateInstallRegistryUrl rejects command-substitution-like override", () => {
expect(
resolveUpdateInstallRegistryUrl({
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873/$(id)",
} as NodeJS.ProcessEnv),
).toBeNull();
});
test("buildLatestVersionUrl constructs expected endpoint", () => {
expect(
buildLatestVersionUrl("@letta-ai/letta-code", "http://localhost:4873/"),
).toBe("http://localhost:4873/@letta-ai/letta-code/latest");
});
test("buildInstallCommand adds registry when configured", () => {
expect(
buildInstallCommand("npm", {
LETTA_UPDATE_INSTALL_REGISTRY_URL: "http://localhost:4873",
} as NodeJS.ProcessEnv),
).toContain("--registry http://localhost:4873");
});
test("buildInstallCommand uses default package and no registry by default", () => {
expect(buildInstallCommand("pnpm", {} as NodeJS.ProcessEnv)).toBe(
"pnpm add -g @letta-ai/letta-code@latest",
);
});
});
describe("checkForUpdate with fetch", () => {
let originalFetch: typeof globalThis.fetch;
let originalRegistry: string | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalRegistry = process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
delete process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalRegistry === undefined) {
delete process.env.LETTA_UPDATE_REGISTRY_BASE_URL;
} else {
process.env.LETTA_UPDATE_REGISTRY_BASE_URL = originalRegistry;
}
});
test("returns updateAvailable when registry version differs", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ version: "99.0.0" }), { status: 200 }),
),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(true);
expect(result.latestVersion).toBe("99.0.0");
expect(result.checkFailed).toBeUndefined();
});
test("returns checkFailed on non-2xx response", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response("Not Found", { status: 404 })),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("returns checkFailed on malformed JSON (no version field)", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ name: "test" }), { status: 200 }),
),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("returns checkFailed on network error", async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error("fetch failed")),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("uses registry override URL", async () => {
process.env.LETTA_UPDATE_REGISTRY_BASE_URL = "http://localhost:4873";
const capturedUrls: string[] = [];
globalThis.fetch = mock((url: string | URL | Request) => {
capturedUrls.push(String(url));
return Promise.resolve(
new Response(JSON.stringify({ version: "99.0.0" }), { status: 200 }),
);
}) as unknown as typeof fetch;
await checkForUpdate();
expect(capturedUrls.length).toBe(1);
expect(capturedUrls[0]).toBe(
"http://localhost:4873/@letta-ai/letta-code/latest",
);
});
});