isolate-package
Version:
Isolate monorepo packages to form a self-contained deployable unit
292 lines (248 loc) • 8.46 kB
text/typescript
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PackageManifest, PackagesRegistry } from "~/lib/types";
import { listInternalPackages } from "./list-internal-packages";
const mockWarn = vi.fn();
vi.mock("~/lib/logger", () => ({
useLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: mockWarn,
error: vi.fn(),
}),
}));
/** Helper to create a minimal WorkspacePackageInfo entry */
function entry(manifest: PackageManifest) {
return {
absoluteDir: `/workspace/packages/${manifest.name}`,
rootRelativeDir: `packages/${manifest.name}`,
manifest,
};
}
describe("listInternalPackages", () => {
afterEach(() => {
mockWarn.mockClear();
});
it("should return an empty array when there are no internal dependencies", () => {
const manifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { lodash: "^4.0.0" },
};
const registry: PackagesRegistry = {
app: entry(manifest),
};
const result = listInternalPackages(manifest, registry);
expect(result).toEqual([]);
expect(mockWarn).not.toHaveBeenCalled();
});
it("should resolve a simple internal dependency", () => {
const utilsManifest: PackageManifest = {
name: "utils",
version: "1.0.0",
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { utils: "workspace:*", lodash: "^4.0.0" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
utils: entry(utilsManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(["utils"]);
expect(mockWarn).not.toHaveBeenCalled();
});
it("should recursively resolve transitive internal dependencies", () => {
const coreManifest: PackageManifest = {
name: "core",
version: "1.0.0",
};
const utilsManifest: PackageManifest = {
name: "utils",
version: "1.0.0",
dependencies: { core: "workspace:*" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { utils: "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
utils: entry(utilsManifest),
core: entry(coreManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(expect.arrayContaining(["utils", "core"]));
expect(result).toHaveLength(2);
expect(mockWarn).not.toHaveBeenCalled();
});
it("should deduplicate diamond dependencies without warning", () => {
const coreManifest: PackageManifest = {
name: "core",
version: "1.0.0",
};
const utilsManifest: PackageManifest = {
name: "utils",
version: "1.0.0",
dependencies: { core: "workspace:*" },
};
const helpersManifest: PackageManifest = {
name: "helpers",
version: "1.0.0",
dependencies: { core: "workspace:*" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { utils: "workspace:*", helpers: "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
utils: entry(utilsManifest),
helpers: entry(helpersManifest),
core: entry(coreManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(
expect.arrayContaining(["utils", "helpers", "core"]),
);
expect(result).toHaveLength(3);
expect(mockWarn).not.toHaveBeenCalled();
});
it("should detect a two-node cycle and log a warning", () => {
/** A depends on B, B depends on A */
const bManifest: PackageManifest = {
name: "b",
version: "1.0.0",
dependencies: { a: "workspace:*" },
};
const aManifest: PackageManifest = {
name: "a",
version: "1.0.0",
dependencies: { b: "workspace:*" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { a: "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
a: entry(aManifest),
b: entry(bManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(expect.arrayContaining(["a", "b"]));
expect(result).toHaveLength(2);
/** Chain: app → a → b → a */
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("app → a → b → a"),
);
});
it("should detect a cycle in nested dependencies and log a warning", () => {
/** App depends on A, A depends on B, B depends on C, C depends on B */
const cManifest: PackageManifest = {
name: "c",
version: "1.0.0",
dependencies: { b: "workspace:*" },
};
const bManifest: PackageManifest = {
name: "b",
version: "1.0.0",
dependencies: { c: "workspace:*" },
};
const aManifest: PackageManifest = {
name: "a",
version: "1.0.0",
dependencies: { b: "workspace:*" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { a: "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
a: entry(aManifest),
b: entry(bManifest),
c: entry(cManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(expect.arrayContaining(["a", "b", "c"]));
expect(result).toHaveLength(3);
/** Chain: app → a → b → c → b */
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("app → a → b → c → b"),
);
});
it("should include devDependencies and handle cycles in them", () => {
const devLibManifest: PackageManifest = {
name: "dev-lib",
version: "1.0.0",
dependencies: { app: "workspace:*" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { lodash: "^4.0.0" },
devDependencies: { "dev-lib": "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
"dev-lib": entry(devLibManifest),
};
/** Without devDependencies — should not find dev-lib */
const withoutDev = listInternalPackages(appManifest, registry);
expect(withoutDev).toEqual([]);
expect(mockWarn).not.toHaveBeenCalled();
/** With devDependencies — should find dev-lib and detect the cycle back to app */
const withDev = listInternalPackages(appManifest, registry, {
includeDevDependencies: true,
});
expect(withDev).toEqual(["dev-lib"]);
/** Chain: app → dev-lib → app */
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("app → dev-lib → app"),
);
});
it("should handle name clash that creates a false cycle (issue #138)", () => {
/**
* Reproduces the crash from issue #138: internal "config" depends on
* "server", and "server" depends on the npm "config" package. Because
* both the internal and external package share the name "config", the
* tool misidentifies the external reference as internal, creating a
* cycle: config → server → config. Without cycle detection this causes
* "Maximum call stack size exceeded".
*/
const configManifest: PackageManifest = {
name: "config",
version: "1.0.0",
dependencies: { server: "workspace:*" },
};
const serverManifest: PackageManifest = {
name: "server",
version: "1.0.0",
/** Intended as npm "config", but misidentified as the internal one */
dependencies: { config: "^3.0.0" },
};
const appManifest: PackageManifest = {
name: "app",
version: "1.0.0",
dependencies: { config: "workspace:*" },
};
const registry: PackagesRegistry = {
app: entry(appManifest),
server: entry(serverManifest),
config: entry(configManifest),
};
const result = listInternalPackages(appManifest, registry);
expect(result).toEqual(expect.arrayContaining(["config", "server"]));
expect(result).toHaveLength(2);
/** The false cycle config → server → config is detected and warned about */
expect(mockWarn).toHaveBeenCalledWith(
expect.stringContaining("app → config → server → config"),
);
});
});