diff --git a/scripts/run-exact-tests.mjs b/scripts/run-exact-tests.mjs index 98e723b3bd..cbc5dd60d9 100644 --- a/scripts/run-exact-tests.mjs +++ b/scripts/run-exact-tests.mjs @@ -77,7 +77,7 @@ try { throw cause; } if ( - !(await hasCaseMismatchedSymlinkComponent(absolute, candidate)) && + !(await hasMismatchedSymlinkComponent(absolute, candidate)) && (await realpath(candidate)) === canonical ) matches = [...matches, ...specifications]; @@ -99,13 +99,14 @@ try { if (shutdownSignal !== undefined) process.exitCode = signalExitCodes[shutdownSignal]; -async function hasCaseMismatchedSymlinkComponent(selected, candidate) { +async function hasMismatchedSymlinkComponent(selected, candidate) { const selectedAbsolute = resolve(selected); const candidateAbsolute = resolve(candidate); const root = parse(selectedAbsolute).root; if (root !== parse(candidateAbsolute).root) return true; const selectedParts = relative(root, selectedAbsolute).split(sep); const candidateParts = relative(root, candidateAbsolute).split(sep); + if (selectedParts.length !== candidateParts.length) return true; let selectedPath = root; let candidatePath = root; for (let index = 0; index < selectedParts.length; index += 1) { @@ -118,14 +119,12 @@ async function hasCaseMismatchedSymlinkComponent(selected, candidate) { candidatePath = candidatePath ? resolve(candidatePath, candidatePart) : candidatePart; - if ( - selectedPart !== candidatePart && - selectedPart.toLowerCase() === candidatePart.toLowerCase() - ) { + if (selectedPart !== candidatePart) { const [selectedEntry, candidateEntry] = await Promise.all([ lstat(selectedPath), lstat(candidatePath), ]); + // Matching inode/realpath does not make an excluded symlink a discovered path. if (selectedEntry.isSymbolicLink() || candidateEntry.isSymbolicLink()) return true; } diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts index 3976781d5a..93e3d9ccca 100644 --- a/tests/boundary/cli/developmentTests.test.ts +++ b/tests/boundary/cli/developmentTests.test.ts @@ -88,6 +88,76 @@ const testCommand = (root: string, ...arguments_: string[]) => { ...process.env, NO_COLOR: "1" }, ); +describe("development test runner symlink aliases", () => { + it("rejects excluded file and directory aliases without running their targets", async ({ + skip, + }) => { + for (const { kind, mode } of [ + { kind: "file", mode: "focused" }, + { kind: "directory", mode: "local" }, + { kind: "directory", mode: "focused" }, + ] as const) { + const root = await fixture(); + const included = + kind === "file" ? join(root, "src") : join(root, "src", "domain"); + const includedTest = join(included, "value.test.ts"); + const alias = + kind === "file" + ? join(root, "src", "excluded.test.ts") + : join(root, "src", "contracts"); + if (kind === "directory") { + await mkdir(included); + for (const name of ["value.ts", "value.test.ts"]) + await copyFile(join(root, "src", name), join(included, name)); + } + try { + await symlink( + kind === "file" ? includedTest : included, + alias, + kind === "directory" + ? process.platform === "win32" + ? "junction" + : "dir" + : undefined, + ); + } catch (cause) { + if ( + process.platform === "win32" && + cause instanceof Error && + "code" in cause && + (cause.code === "EPERM" || cause.code === "EACCES") + ) + skip("Windows symlink creation requires unavailable privileges"); + throw cause; + } + const selectedAlias = + kind === "file" + ? "src/excluded.test.ts" + : "src/contracts/value.test.ts"; + const includedSelection = + kind === "file" ? "src/value.test.ts" : "src/domain/value.test.ts"; + await writeFile( + join(root, "vitest.config.mjs"), + `export default { test: { projects: [{ test: { name: "domain", include: ["${includedSelection}"] } }] } };\n`, + ); + + await expect( + testCommand(root, mode, selectedAlias), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining( + `Selected test files were not discovered: ${selectedAlias}`, + ), + }); + await expect( + readFile(join(root, "selected.txt"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + await testCommand(root, mode, includedSelection); + expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1"); + } + }); +}); + describe("development test runner exact selection", () => { it("matches selected path casing to the host filesystem", async () => { const root = await fixture();