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
4 changes: 3 additions & 1 deletion script/node-polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
spawnSync as nodeSpawnSync,
} from "node:child_process";
import { statSync } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { access, readFile, stat, writeFile } from "node:fs/promises";
// node:sqlite is imported lazily inside NodeDatabasePolyfill to avoid
// crashing on Node.js versions without node:sqlite support when the
// bundle is loaded as a library (the consumer may never use SQLite).
Expand Down Expand Up @@ -127,6 +127,8 @@ const BunPolyfill = {
return false;
}
},
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
stat: stat.bind(null, path),
text(): Promise<string> {
return readFile(path, "utf-8");
},
Expand Down
102 changes: 102 additions & 0 deletions test/lib/node-polyfills-file-stat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* CLI-1EA / CLI-1EB regression: the npm distribution's Bun.file() polyfill
* was missing `.stat()`, causing every DSN auto-detection call on Node to
* throw `TypeError: Bun.file(...).stat is not a function`.
*
* This file lives under `test/lib/` so it's picked up by `bun run test:unit`
* (the primary `test/script/node-polyfills.test.ts` is outside the CI
* globs — see the "test:unit glob" gotcha in AGENTS.md).
*
* We reproduce the minimal shape of the polyfill inline to mirror the test
* pattern in `test/script/node-polyfills.test.ts`. If the polyfill's
* `.stat()` shim changes shape in `script/node-polyfills.ts`, update this
* reproduction too.
*/

import { describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

/**
* Mirrors the `.stat` member of the object returned by
* `BunPolyfill.file(path)` in `script/node-polyfills.ts`.
*/
function polyfillFileStat(
path: string
): () => Promise<import("node:fs").Stats> {
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
return stat.bind(null, path);
}

describe("node polyfill Bun.file().stat() (CLI-1EA, CLI-1EB)", () => {
test("regular file resolves with isFile()=true", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
const filePath = join(tmpDir, "regular.txt");
try {
writeFileSync(filePath, "hello");
const stats = await polyfillFileStat(filePath)();
expect(stats.isFile()).toBe(true);
expect(stats.isDirectory()).toBe(false);
expect(stats.size).toBe(5);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("directory resolves with isDirectory()=true, isFile()=false", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
try {
const stats = await polyfillFileStat(tmpDir)();
expect(stats.isDirectory()).toBe(true);
expect(stats.isFile()).toBe(false);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("non-existent path rejects with ENOENT", async () => {
const statFn = polyfillFileStat("/tmp/__nonexistent_cli_1ea_test__");
try {
await statFn();
throw new Error("expected stat to reject");
} catch (err) {
expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
}
});

test("follows symlinks (returns target type, matches Bun.file().stat())", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
const targetPath = join(tmpDir, "target.txt");
const linkPath = join(tmpDir, "link.txt");
try {
writeFileSync(targetPath, "data");
execSync(
`ln -s ${JSON.stringify(targetPath)} ${JSON.stringify(linkPath)}`
);
const stats = await polyfillFileStat(linkPath)();
// stat (not lstat) follows the symlink; we must see the regular file.
expect(stats.isFile()).toBe(true);
expect(stats.isSymbolicLink()).toBe(false);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("parity with native Bun.file().stat()", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
const filePath = join(tmpDir, "compare.txt");
try {
writeFileSync(filePath, "compare");
const polyfillStats = await polyfillFileStat(filePath)();
const bunStats = await Bun.file(filePath).stat();
expect(polyfillStats.isFile()).toBe(bunStats.isFile());
expect(polyfillStats.isDirectory()).toBe(bunStats.isDirectory());
expect(polyfillStats.size).toBe(bunStats.size);
} finally {
rmSync(tmpDir, { recursive: true });
}
});
});
77 changes: 77 additions & 0 deletions test/script/node-polyfills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
spawnSync as nodeSpawnSync,
} from "node:child_process";
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -339,6 +340,8 @@ function polyfillFile(path: string) {
return 0;
}
},
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
stat: stat.bind(null, path),
};
}

Expand Down Expand Up @@ -415,6 +418,80 @@ describe("file polyfill size and lastModified", () => {
});
});

describe("file polyfill stat() (CLI-1EA, CLI-1EB regression)", () => {
test("stat() resolves to a Stats object with isFile()=true for a regular file", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
const filePath = join(tmpDir, "regular.txt");
try {
writeFileSync(filePath, "hello");
const pf = polyfillFile(filePath);
const stats = await pf.stat();
expect(stats.isFile()).toBe(true);
expect(stats.isDirectory()).toBe(false);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("stat() reports a directory as !isFile()", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
try {
const pf = polyfillFile(tmpDir);
const stats = await pf.stat();
expect(stats.isFile()).toBe(false);
expect(stats.isDirectory()).toBe(true);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("stat() throws ENOENT for a non-existent path", async () => {
const pf = polyfillFile("/tmp/__nonexistent_polyfill_stat_test__");
try {
await pf.stat();
throw new Error("expected stat() to throw");
} catch (err) {
expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
}
});

test("stat() follows symlinks (returns target type, not lstat)", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
const targetPath = join(tmpDir, "target.txt");
const linkPath = join(tmpDir, "link.txt");
try {
writeFileSync(targetPath, "data");
// Create a symlink link.txt → target.txt
execSync(
`ln -s ${JSON.stringify(targetPath)} ${JSON.stringify(linkPath)}`
);
const pf = polyfillFile(linkPath);
const stats = await pf.stat();
// stat (not lstat) follows the symlink to the regular file target.
expect(stats.isFile()).toBe(true);
expect(stats.isSymbolicLink()).toBe(false);
} finally {
rmSync(tmpDir, { recursive: true });
}
});

test("stat() is consistent with Bun.file().stat()", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
const filePath = join(tmpDir, "compare.txt");
try {
writeFileSync(filePath, "compare");
const pf = polyfillFile(filePath);
const polyfillStats = await pf.stat();
const bunStats = await Bun.file(filePath).stat();
expect(polyfillStats.isFile()).toBe(bunStats.isFile());
expect(polyfillStats.isDirectory()).toBe(bunStats.isDirectory());
expect(polyfillStats.size).toBe(bunStats.size);
} finally {
rmSync(tmpDir, { recursive: true });
}
});
});

/**
* Reproduces the exact which() polyfill logic from script/node-polyfills.ts
* with PATH option support.
Expand Down
Loading