Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
f61a3a90a2 fix(test): apply --preserve-symlinks-main to test file path resolution
When running tests via a symlink, Bun was resolving the symlink before
constructing snapshot paths, causing snapshot lookups to fail even when
`--preserve-symlinks-main` was specified.

This fix has two parts:

1. In `test_command.zig`, pass the `preserve_symlinks_main` flag through
   to the test runner and set `vm.transpiler.resolver.opts.preserve_symlinks`
   before resolving the entry point (similar to `run_command.zig`).

2. In `resolver.zig`, wrap the symlink resolution in `finalizeResult()`
   with a `preserve_symlinks` check. This was the actual cause of the
   bug - `finalizeResult()` was unconditionally resolving symlinks via
   `path.setRealpath()`, which overwrites the path text with the resolved
   symlink target.

Fixes #26695

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:01:54 +00:00
3 changed files with 204 additions and 40 deletions

View File

@@ -1538,7 +1538,7 @@ pub const TestCommand = struct {
else => {},
}
runAllTests(reporter, vm, test_files, ctx.allocator);
runAllTests(reporter, vm, test_files, ctx.allocator, ctx.runtime_options.preserve_symlinks_main or bun.env_var.NODE_PRESERVE_SYMLINKS_MAIN.get());
}
const write_snapshots_success = try jest.Jest.runner.?.snapshots.writeInlineSnapshots();
@@ -1809,12 +1809,14 @@ pub const TestCommand = struct {
vm_: *jsc.VirtualMachine,
files_: []const PathString,
allocator_: std.mem.Allocator,
preserve_symlinks_main: bool,
) void {
const Context = struct {
reporter: *CommandLineReporter,
vm: *jsc.VirtualMachine,
files: []const PathString,
allocator: std.mem.Allocator,
preserve_symlinks_main: bool,
pub fn begin(this: *@This()) void {
const reporter = this.reporter;
const vm = this.vm;
@@ -1823,13 +1825,13 @@ pub const TestCommand = struct {
if (files.len > 1) {
for (files[0 .. files.len - 1], 0..) |file_name, i| {
TestCommand.run(reporter, vm, file_name.slice(), .{ .first = i == 0, .last = false }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err);
TestCommand.run(reporter, vm, file_name.slice(), .{ .first = i == 0, .last = false }, this.preserve_symlinks_main) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err);
reporter.jest.default_timeout_override = std.math.maxInt(u32);
Global.mimalloc_cleanup(false);
}
}
TestCommand.run(reporter, vm, files[files.len - 1].slice(), .{ .first = files.len == 1, .last = true }) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err);
TestCommand.run(reporter, vm, files[files.len - 1].slice(), .{ .first = files.len == 1, .last = true }, this.preserve_symlinks_main) catch |err| handleTopLevelTestErrorBeforeJavaScriptStart(err);
}
};
@@ -1837,7 +1839,7 @@ pub const TestCommand = struct {
vm_.eventLoop().ensureWaker();
vm_.arena = &arena;
vm_.allocator = arena.allocator();
var ctx = Context{ .reporter = reporter_, .vm = vm_, .files = files_, .allocator = allocator_ };
var ctx = Context{ .reporter = reporter_, .vm = vm_, .files = files_, .allocator = allocator_, .preserve_symlinks_main = preserve_symlinks_main };
vm_.runWithAPILock(Context, &ctx, Context.begin);
}
@@ -1848,6 +1850,7 @@ pub const TestCommand = struct {
vm: *jsc.VirtualMachine,
file_name: string,
first_last: bun_test.BunTestRoot.FirstLast,
preserve_symlinks_main: bool,
) !void {
defer {
js_ast.Expr.Data.Store.reset();
@@ -1866,6 +1869,13 @@ pub const TestCommand = struct {
const prev_only = reporter.jest.only;
defer reporter.jest.only = prev_only;
// Apply preserve_symlinks_main for the entire test file execution. This affects
// resolveEntryPoint (which determines the test file path used for snapshot lookups)
// and any other resolver operations during the test run. Restored when run() returns.
const prev_preserve_symlinks = vm.transpiler.resolver.opts.preserve_symlinks;
defer vm.transpiler.resolver.opts.preserve_symlinks = prev_preserve_symlinks;
vm.transpiler.resolver.opts.preserve_symlinks = preserve_symlinks_main;
const resolution = try vm.transpiler.resolveEntryPoint(file_name);
try vm.clearEntryPoint();

View File

@@ -1009,51 +1009,54 @@ pub const Resolver = struct {
module_type = ModuleTypeMap.getWithLength(path.name.ext, 4) orelse .unknown;
}
if (dir.getEntries(r.generation)) |entries| {
if (entries.get(path.name.filename)) |query| {
const symlink_path = query.entry.symlink(&r.fs.fs, r.store_fd);
if (symlink_path.len > 0) {
path.setRealpath(symlink_path);
if (!result.file_fd.isValid()) result.file_fd = query.entry.cache.fd;
// Only resolve symlinks if preserve_symlinks is not set
if (!r.opts.preserve_symlinks) {
if (dir.getEntries(r.generation)) |entries| {
if (entries.get(path.name.filename)) |query| {
const symlink_path = query.entry.symlink(&r.fs.fs, r.store_fd);
if (symlink_path.len > 0) {
path.setRealpath(symlink_path);
if (!result.file_fd.isValid()) result.file_fd = query.entry.cache.fd;
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Resolved symlink \"{s}\" to \"{s}\"", .{ path.text, symlink_path });
}
} else if (dir.abs_real_path.len > 0) {
// When the directory is a symlink, we don't need to call getFdPath.
var parts = [_]string{ dir.abs_real_path, query.entry.base() };
var buf: bun.PathBuffer = undefined;
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Resolved symlink \"{s}\" to \"{s}\"", .{ path.text, symlink_path });
}
} else if (dir.abs_real_path.len > 0) {
// When the directory is a symlink, we don't need to call getFdPath.
var parts = [_]string{ dir.abs_real_path, query.entry.base() };
var buf: bun.PathBuffer = undefined;
const out = r.fs.absBuf(&parts, &buf);
const out = r.fs.absBuf(&parts, &buf);
const store_fd = r.store_fd;
const store_fd = r.store_fd;
if (!query.entry.cache.fd.isValid() and store_fd) {
buf[out.len] = 0;
const span = buf[0..out.len :0];
var file: bun.FD = .fromStdFile(try std.fs.openFileAbsoluteZ(span, .{ .mode = .read_only }));
query.entry.cache.fd = file;
Fs.FileSystem.setMaxFd(file.native());
}
if (!query.entry.cache.fd.isValid() and store_fd) {
buf[out.len] = 0;
const span = buf[0..out.len :0];
var file: bun.FD = .fromStdFile(try std.fs.openFileAbsoluteZ(span, .{ .mode = .read_only }));
query.entry.cache.fd = file;
Fs.FileSystem.setMaxFd(file.native());
}
defer {
if (r.fs.fs.needToCloseFiles()) {
if (query.entry.cache.fd.isValid()) {
var file = query.entry.cache.fd.stdFile();
file.close();
query.entry.cache.fd = .invalid;
defer {
if (r.fs.fs.needToCloseFiles()) {
if (query.entry.cache.fd.isValid()) {
var file = query.entry.cache.fd.stdFile();
file.close();
query.entry.cache.fd = .invalid;
}
}
}
}
const symlink = try Fs.FileSystem.FilenameStore.instance.append(@TypeOf(out), out);
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Resolved symlink \"{s}\" to \"{s}\"", .{ symlink, path.text });
}
query.entry.cache.symlink = PathString.init(symlink);
if (!result.file_fd.isValid() and store_fd) result.file_fd = query.entry.cache.fd;
const symlink = try Fs.FileSystem.FilenameStore.instance.append(@TypeOf(out), out);
if (r.debug_logs) |*debug| {
debug.addNoteFmt("Resolved symlink \"{s}\" to \"{s}\"", .{ symlink, path.text });
}
query.entry.cache.symlink = PathString.init(symlink);
if (!result.file_fd.isValid() and store_fd) result.file_fd = query.entry.cache.fd;
path.setRealpath(symlink);
path.setRealpath(symlink);
}
}
}
}

View File

@@ -0,0 +1,151 @@
import { expect, test } from "bun:test";
import { mkdirSync, symlinkSync, writeFileSync } from "fs";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
// Test for https://github.com/oven-sh/bun/issues/26695
// --preserve-symlinks-main should apply to test file path resolution for snapshot tests
// Skip on Windows: symlinkSync requires elevated privileges or developer mode
test.skipIf(isWindows)("--preserve-symlinks-main applies to snapshot test file path resolution", async () => {
using dir = tempDir("symlink-snapshot-test", {});
// Create real directory structure
const realDir = join(String(dir), "real-dir");
mkdirSync(realDir);
mkdirSync(join(realDir, "__snapshots__"));
// Create test file in real directory
writeFileSync(
join(realDir, "test.test.ts"),
`import { test, expect } from "bun:test";
test("snapshot", () => {
expect({ hello: "world" }).toMatchSnapshot();
});
`,
);
// Create snapshot in real directory - this should NOT be used when running via symlink
writeFileSync(
join(realDir, "__snapshots__", "test.test.ts.snap"),
`exports[\`snapshot 1\`] = \`
{
"hello": "real-dir-snapshot",
}
\`;
`,
);
// Create symlink directory structure
const symlinkDir = join(String(dir), "symlink-dir");
mkdirSync(symlinkDir);
mkdirSync(join(symlinkDir, "__snapshots__"));
// Create symlink to the test file
symlinkSync(join(realDir, "test.test.ts"), join(symlinkDir, "test.test.ts"));
// Create snapshot in symlink directory - this SHOULD be used when running via symlink with --preserve-symlinks-main
writeFileSync(
join(symlinkDir, "__snapshots__", "test.test.ts.snap"),
`exports[\`snapshot 1\`] = \`
{
"hello": "world",
}
\`;
`,
);
// Run test via symlink WITH --preserve-symlinks-main
// The snapshot should be looked up in symlink-dir, not real-dir
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--preserve-symlinks-main", "test.test.ts"],
cwd: symlinkDir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// With --preserve-symlinks-main, the test should pass because it uses
// symlink-dir/__snapshots__/test.test.ts.snap (which has "world")
// Without the fix, it would look in real-dir/__snapshots__/test.test.ts.snap (which has "real-dir-snapshot")
expect(stderr).toContain("1 pass");
expect(stderr).not.toContain("real-dir-snapshot");
expect(exitCode).toBe(0);
});
// Skip on Windows: symlinkSync requires elevated privileges or developer mode
test.skipIf(isWindows)("snapshot uses resolved path without --preserve-symlinks-main", async () => {
using dir = tempDir("symlink-snapshot-test-no-flag", {});
// Create real directory structure
const realDir = join(String(dir), "real-dir");
mkdirSync(realDir);
mkdirSync(join(realDir, "__snapshots__"));
// Create test file in real directory
writeFileSync(
join(realDir, "test.test.ts"),
`import { test, expect } from "bun:test";
test("snapshot", () => {
expect({ hello: "world" }).toMatchSnapshot();
});
`,
);
// Create snapshot in real directory - this SHOULD be used without --preserve-symlinks-main
writeFileSync(
join(realDir, "__snapshots__", "test.test.ts.snap"),
`exports[\`snapshot 1\`] = \`
{
"hello": "world",
}
\`;
`,
);
// Create symlink directory structure
const symlinkDir = join(String(dir), "symlink-dir");
mkdirSync(symlinkDir);
mkdirSync(join(symlinkDir, "__snapshots__"));
// Create symlink to the test file
symlinkSync(join(realDir, "test.test.ts"), join(symlinkDir, "test.test.ts"));
// Create a DIFFERENT snapshot in symlink directory
writeFileSync(
join(symlinkDir, "__snapshots__", "test.test.ts.snap"),
`exports[\`snapshot 1\`] = \`
{
"hello": "wrong-snapshot",
}
\`;
`,
);
// Run test via symlink WITHOUT --preserve-symlinks-main
// The snapshot should be looked up in real-dir (symlink resolved)
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "test.test.ts"],
cwd: symlinkDir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
// Without --preserve-symlinks-main, the test should pass because it uses
// real-dir/__snapshots__/test.test.ts.snap (which has "world")
expect(stderr).toContain("1 pass");
expect(stderr).not.toContain("wrong-snapshot");
expect(exitCode).toBe(0);
});