diff --git a/script/node-polyfills.ts b/script/node-polyfills.ts index 49aa9c5ac..12fb360dc 100644 --- a/script/node-polyfills.ts +++ b/script/node-polyfills.ts @@ -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). @@ -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 { return readFile(path, "utf-8"); }, diff --git a/test/lib/node-polyfills-file-stat.test.ts b/test/lib/node-polyfills-file-stat.test.ts new file mode 100644 index 000000000..d39588113 --- /dev/null +++ b/test/lib/node-polyfills-file-stat.test.ts @@ -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 { + // 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 }); + } + }); +}); diff --git a/test/script/node-polyfills.test.ts b/test/script/node-polyfills.test.ts index f6da4ac48..9fe800856 100644 --- a/test/script/node-polyfills.test.ts +++ b/test/script/node-polyfills.test.ts @@ -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"; @@ -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), }; } @@ -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.