From 2464c82fb766f9aed2bde324ac3e1cae035e03f1 Mon Sep 17 00:00:00 2001 From: daeho im Date: Mon, 14 Sep 2026 15:12:58 +0900 Subject: [PATCH 1/3] fix(fixture): keep relative symlinks inside the copied fixture `fs.cp` resolves relative symlink targets to absolute paths unless `verbatimSymlinks` is set. A link inside the source directory therefore pointed back at the source after the copy, so writing through it from a test modified the original tree and `cleanup()` could not undo it. Claude-Session: https://claude.ai/code/session_01PNx3qJzFSFUwKNNTVtPFE7 --- src/fixture.spec.ts | 24 ++++++++++++++++++++++++ src/fixture.ts | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/fixture.spec.ts b/src/fixture.spec.ts index cb21977..2614606 100644 --- a/src/fixture.spec.ts +++ b/src/fixture.spec.ts @@ -3,6 +3,7 @@ import { Fixture } from "./fixture.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs/promises"; +import os from "node:os"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testFixture = (name: string) => path.join(__dirname, "..", "fixtures", name); @@ -34,6 +35,29 @@ describe("Fixture", () => { await fs.readFile(path.join(testFixture("basic"), "src/index.ts"), "utf-8"), ); }); + + // Windows needs an elevated process to create symlinks, so this runs on POSIX only. + it.skipIf(process.platform === "win32")( + "should keep a relative symlink pointing inside the copy", + async () => { + const source = await fs.mkdtemp(path.join(os.tmpdir(), "fixture-kit-source-")); + + try { + await fs.writeFile(path.join(source, "target.txt"), "original"); + await fs.symlink("target.txt", path.join(source, "link.txt")); + + await using fixture = await Fixture.fromDirectory(source); + await fs.writeFile(path.join(fixture.root, "link.txt"), "written through the link"); + + expect(await fs.readFile(path.join(fixture.root, "target.txt"), "utf-8")).toBe( + "written through the link", + ); + expect(await fs.readFile(path.join(source, "target.txt"), "utf-8")).toBe("original"); + } finally { + await fs.rm(source, { recursive: true, force: true }); + } + }, + ); }); describe("create", () => { diff --git a/src/fixture.ts b/src/fixture.ts index 3a45ecb..abe0446 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -32,7 +32,10 @@ export class Fixture implements AsyncDisposable { throw new Error(`source must be a directory: ${sourcePath}`); } - await fs.cp(sourcePath, fixture.root, { recursive: true }); + // `verbatimSymlinks` keeps relative symlink targets as written. Without it `fs.cp` + // rewrites them to absolute paths pointing back at `sourcePath`, so a link inside the + // copy resolves into the source directory instead of the fixture. + await fs.cp(sourcePath, fixture.root, { recursive: true, verbatimSymlinks: true }); return fixture; } catch (error) { From 18c7aaccda18276ab0c735d9905732c98d60b454 Mon Sep 17 00:00:00 2001 From: daeho im Date: Mon, 14 Sep 2026 15:13:20 +0900 Subject: [PATCH 2/3] docs(fixture): document symlink handling in fromDirectory Claude-Session: https://claude.ai/code/session_01PNx3qJzFSFUwKNNTVtPFE7 --- .changeset/nervous-donkeys-hammer.md | 5 +++++ README.ko.md | 1 + README.md | 1 + 3 files changed, 7 insertions(+) create mode 100644 .changeset/nervous-donkeys-hammer.md diff --git a/.changeset/nervous-donkeys-hammer.md b/.changeset/nervous-donkeys-hammer.md new file mode 100644 index 0000000..1450803 --- /dev/null +++ b/.changeset/nervous-donkeys-hammer.md @@ -0,0 +1,5 @@ +--- +"@fixture-kit/core": patch +--- + +Keep relative symlinks inside the copy in `Fixture.fromDirectory`. `fs.cp` rewrites relative symlink targets to absolute paths unless `verbatimSymlinks` is set, so a link in the source directory pointed back at the source after the copy. diff --git a/README.ko.md b/README.ko.md index a0cd54a..834ff2a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -179,6 +179,7 @@ static fromDirectory(directory: string): Promise - `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요. - 경로가 존재하지 않거나 디렉터리가 아니면 에러를 던져요. 실패하면 임시 디렉터리를 삭제한 뒤 에러를 다시 던져요. +- 심볼릭 링크는 타겟을 그대로 둔 채 링크로 복사해요. 상대 경로 링크는 복사본 안에서 해석되고, 절대 경로 링크는 소스에서 가리키던 곳을 그대로 가리켜요. ### `fixture.root` diff --git a/README.md b/README.md index 99e7e6b..a709698 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ static fromDirectory(directory: string): Promise - `directory` — path of the source directory. Relative paths are resolved from the current working directory. - Throws if the path doesn't exist or isn't a directory. On failure, the temporary directory is removed before the error is rethrown. +- Symlinks are copied as symlinks with their targets left as written. A relative link resolves inside the copy; an absolute link still points wherever it pointed in the source. ### `fixture.root` From 5fc02f6ceaf3280887ed396e4cd2f363144e547d Mon Sep 17 00:00:00 2001 From: daeho im Date: Mon, 14 Sep 2026 16:47:02 +0900 Subject: [PATCH 3/3] docs: drop extra fromDirectory symlink explanation Keep verbatimSymlinks in the copy; the README bullets and the inline comment restated the test and the changeset. --- README.ko.md | 1 - README.md | 1 - src/fixture.ts | 3 --- 3 files changed, 5 deletions(-) diff --git a/README.ko.md b/README.ko.md index 834ff2a..a0cd54a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -179,7 +179,6 @@ static fromDirectory(directory: string): Promise - `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요. - 경로가 존재하지 않거나 디렉터리가 아니면 에러를 던져요. 실패하면 임시 디렉터리를 삭제한 뒤 에러를 다시 던져요. -- 심볼릭 링크는 타겟을 그대로 둔 채 링크로 복사해요. 상대 경로 링크는 복사본 안에서 해석되고, 절대 경로 링크는 소스에서 가리키던 곳을 그대로 가리켜요. ### `fixture.root` diff --git a/README.md b/README.md index a709698..99e7e6b 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,6 @@ static fromDirectory(directory: string): Promise - `directory` — path of the source directory. Relative paths are resolved from the current working directory. - Throws if the path doesn't exist or isn't a directory. On failure, the temporary directory is removed before the error is rethrown. -- Symlinks are copied as symlinks with their targets left as written. A relative link resolves inside the copy; an absolute link still points wherever it pointed in the source. ### `fixture.root` diff --git a/src/fixture.ts b/src/fixture.ts index abe0446..0f76ca5 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -32,9 +32,6 @@ export class Fixture implements AsyncDisposable { throw new Error(`source must be a directory: ${sourcePath}`); } - // `verbatimSymlinks` keeps relative symlink targets as written. Without it `fs.cp` - // rewrites them to absolute paths pointing back at `sourcePath`, so a link inside the - // copy resolves into the source directory instead of the fixture. await fs.cp(sourcePath, fixture.root, { recursive: true, verbatimSymlinks: true }); return fixture;