Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion packages/bundlecheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ npm install @node-cli/bundlecheck
### Basic Usage

```js
import { getBundleStats, getBundleTrend, getPackageVersions } from "@node-cli/bundlecheck";
import { getBundleStats, getBundleTrend, getPackageVersions, getPackageExports } from "@node-cli/bundlecheck";

// Get bundle stats for a single package
const stats = await getBundleStats({
Expand Down Expand Up @@ -226,6 +226,13 @@ const versions = await getPackageVersions({
});
console.log(versions.tags.latest); // "7.0.0"
console.log(versions.versions.slice(0, 5)); // ["7.0.0", "6.0.21", "6.0.20", ...]

// Get named exports from a package
const exports = await getPackageExports({
package: "date-fns@3.6.0",
});
console.log(`Found ${exports.count} exports`);
console.log(exports.exports.slice(0, 5).map(e => e.name)); // ["add", "addBusinessDays", "addDays", ...]
```

### API Reference
Expand Down Expand Up @@ -263,6 +270,7 @@ type BundleStats = {
rawSizeFormatted: string; // Human-readable (e.g., "45.2 kB")
gzipSizeFormatted: string | null;
fromCache: boolean; // Whether result was from cache
namedExportCount: number; // Total named exports in package
};
```

Expand Down Expand Up @@ -333,6 +341,47 @@ type PackageVersions = {
};
```

#### `getPackageExports(options)`

Get the named exports of an npm package by analyzing its TypeScript declarations.

**Options:**

| Option | Type | Default | Description |
| ---------- | -------- | ---------- | ------------------------------------------------ |
| `package` | `string` | (required) | Package name with optional version |
| `registry` | `string` | `undefined`| Custom npm registry URL |

**Returns:** `Promise<PackageExports>`

```ts
type PackageExports = {
packageName: string; // Package name
packageVersion: string; // Resolved version
exports: PackageExport[]; // All named exports (including types)
count: number; // Total count (including types)
runtimeExports: PackageExport[]; // Runtime exports only (no types/interfaces)
runtimeCount: number; // Count of runtime exports
};

type PackageExport = {
name: string; // Export name (e.g., "Button")
kind: "function" | "class" | "const" | "type" | "interface" | "enum" | "unknown";
};
```

**Example:**

```js
const result = await getPackageExports({
package: "@mantine/core",
});

console.log(result.count); // 1056 (all exports including types)
console.log(result.runtimeCount); // 365 (only importable exports)
console.log(result.runtimeExports[0]); // { name: "Accordion", kind: "unknown" }
```

### Utility Functions

```js
Expand Down
306 changes: 306 additions & 0 deletions packages/bundlecheck/src/__tests__/exports-installer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as exports from "../exports.js";

// Mock modules
vi.mock("node:child_process");
vi.mock("node:fs");
vi.mock("node:os");
vi.mock("../exports.js");

describe("exports-installer", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset module to clear cached usePnpm value
vi.resetModules();

// Default mocks
vi.mocked(os.tmpdir).mockReturnValue("/tmp");
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
vi.mocked(fs.rmSync).mockReturnValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: "1.0.0" }),
);
vi.mocked(execSync).mockReturnValue(Buffer.from(""));
vi.mocked(exports.getNamedExports).mockReturnValue({
exports: [{ name: "TestExport", kind: "function" }],
count: 1,
runtimeExports: [{ name: "TestExport", kind: "function" }],
runtimeCount: 1,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("installPackage", () => {
it("should install a package and return its exports", async () => {
const { installPackage } = await import("../exports-installer.js");
const result = await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(result.version).toBe("1.0.0");
expect(result.exports).toEqual([
{ name: "TestExport", kind: "function" },
]);
expect(result.runtimeExports).toEqual([
{ name: "TestExport", kind: "function" },
]);
});

it("should create a temporary directory", async () => {
const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining("bundlecheck-exports-"),
{ recursive: true },
);
});

it("should write package.json with the correct dependencies", async () => {
const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "@scope/test-package",
version: "2.0.0",
});

expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining("package.json"),
expect.stringContaining('"@scope/test-package": "2.0.0"'),
);
});

it("should use pnpm when available", async () => {
// pnpm is available (no error thrown)
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
return Buffer.from("8.0.0");
}
return Buffer.from("");
});

const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("pnpm install"),
expect.any(Object),
);
});

it("should fall back to npm when pnpm is not available", async () => {
// pnpm is not available
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
throw new Error("pnpm not found");
}
return Buffer.from("");
});

const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("npm install"),
expect.any(Object),
);
});

it("should include registry flag when registry is provided", async () => {
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
return Buffer.from("8.0.0");
}
return Buffer.from("");
});

const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
registry: "https://registry.example.com",
});

expect(execSync).toHaveBeenCalledWith(
expect.stringContaining('--registry "https://registry.example.com/"'),
expect.any(Object),
);
});

it("should reject invalid registry URLs", async () => {
const { installPackage } = await import("../exports-installer.js");
await expect(
installPackage({
packageName: "test-package",
version: "1.0.0",
registry: "not-a-valid-url",
}),
).rejects.toThrow("Invalid registry URL");
});

it("should reject non-http/https registry URLs", async () => {
const { installPackage } = await import("../exports-installer.js");
await expect(
installPackage({
packageName: "test-package",
version: "1.0.0",
registry: "ftp://registry.example.com",
}),
).rejects.toThrow("Invalid registry URL protocol");
});

it("should read the installed package version from node_modules", async () => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: "1.2.3" }),
);

const { installPackage } = await import("../exports-installer.js");
const result = await installPackage({
packageName: "test-package",
version: "^1.0.0",
});

expect(result.version).toBe("1.2.3");
});

it("should use provided version when package.json read fails", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);

const { installPackage } = await import("../exports-installer.js");
const result = await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(result.version).toBe("1.0.0");
});

it("should clean up temporary directory after success", async () => {
const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining("bundlecheck-exports-"),
{ recursive: true, force: true },
);
});

it("should clean up temporary directory after failure", async () => {
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
return Buffer.from("8.0.0");
}
throw new Error("Install failed");
});

const { installPackage } = await import("../exports-installer.js");
await expect(
installPackage({
packageName: "test-package",
version: "1.0.0",
}),
).rejects.toThrow("Install failed");

expect(fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining("bundlecheck-exports-"),
{ recursive: true, force: true },
);
});

it("should ignore cleanup errors", async () => {
vi.mocked(fs.rmSync).mockImplementation(() => {
throw new Error("Cleanup failed");
});

const { installPackage } = await import("../exports-installer.js");
// Should not throw
const result = await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(result.exports).toBeDefined();
});

it("should call getNamedExports with correct arguments", async () => {
const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "@scope/test-package",
version: "1.0.0",
});

expect(exports.getNamedExports).toHaveBeenCalledWith(
expect.stringContaining("bundlecheck-exports-"),
"@scope/test-package",
);
});

it("should handle npm install with --legacy-peer-deps and --ignore-scripts", async () => {
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
throw new Error("pnpm not found");
}
return Buffer.from("");
});

const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("--legacy-peer-deps"),
expect.any(Object),
);
expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("--ignore-scripts"),
expect.any(Object),
);
});

it("should handle pnpm install with --ignore-scripts and --no-frozen-lockfile", async () => {
vi.mocked(execSync).mockImplementation((cmd) => {
if (String(cmd) === "pnpm --version") {
return Buffer.from("8.0.0");
}
return Buffer.from("");
});

const { installPackage } = await import("../exports-installer.js");
await installPackage({
packageName: "test-package",
version: "1.0.0",
});

expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("--ignore-scripts"),
expect.any(Object),
);
expect(execSync).toHaveBeenCalledWith(
expect.stringContaining("--no-frozen-lockfile"),
expect.any(Object),
);
});
});
});
Loading