From 62230af1fca17eb8dfc7aa387806de3022a39800 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:42:40 +0200 Subject: [PATCH] test(web): isolate startup fixtures from inherited npm_execpath casing pnpm script invocations on Windows expose npm_execpath as NPM_EXECPATH. The fixture's exact-case delete left the inherited key in the child environment, so the launcher used the real pnpm instead of the fixture shim/entry: migrations exited 0 without writing the marker and the exit-7 gate served anyway. Remove every case variant before the runner is selected and cover both runner modes with a regression that restores the inherited value. --- apps/web/tests/unit/startup-process.test.ts | 56 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/unit/startup-process.test.ts b/apps/web/tests/unit/startup-process.test.ts index ef93890e..8fdeaa59 100644 --- a/apps/web/tests/unit/startup-process.test.ts +++ b/apps/web/tests/unit/startup-process.test.ts @@ -49,6 +49,40 @@ async function readMigrationLog(root: string) { return (await readFile(join(root, "migration"), "utf8")).replace(/\r\n/g, "\n"); } +// Windows environment variable names are case-insensitive, so an inherited +// NPM_EXECPATH can shadow the fixture's lowercase name (or defeat deleting it). +function removeCaseInsensitiveEnvKey(env: NodeJS.ProcessEnv, name: string) { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === name) { + delete env[key]; + } + } +} + +// A package manager JavaScript entry that succeeds without recording migrations. +// Used to simulate an inherited npm_execpath in a different key case. +async function writeDecoyEntry() { + const directory = await mkdtemp(join(tmpdir(), "macro-startup-decoy-")); + directories.push(directory); + const entry = join(directory, "decoy-pnpm.cjs"); + await writeFile(entry, "process.exit(0);\n"); + return entry; +} + +async function withInheritedExecpathVariant(run: () => Promise) { + const previous = process.env.NPM_EXECPATH; + process.env.NPM_EXECPATH = await writeDecoyEntry(); + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env.NPM_EXECPATH; + } else { + process.env.NPM_EXECPATH = previous; + } + } +} + afterEach(async () => { for (const child of children.splice(0)) { await waitForExit(child); @@ -97,6 +131,7 @@ async function fixture({ DATABASE_URL: databaseUrl, NEXT_SERVER_HOSTNAME: "127.0.0.1", }; + removeCaseInsensitiveEnvKey(env, "npm_execpath"); if (migrationRunner === "node-entry") { const entry = join(root, "fake-pnpm.mjs"); @@ -111,7 +146,6 @@ async function fixture({ ); env.npm_execpath = entry; } else { - delete env.npm_execpath; const bin = join(root, "bin"); await mkdir(bin); if (process.platform === "win32") { @@ -226,6 +260,26 @@ it("runs the package manager entry provided through npm_execpath", async () => { ); }); +it("ignores an inherited case-variant npm_execpath in the path-shim fixture", async () => { + await withInheritedExecpathVariant(async () => { + const { root, ready } = await fixture(); + await ready; + expect(await readMigrationLog(root)).toBe( + "--filter @macro-tracker/db db:migrate\n", + ); + }); +}); + +it("keeps the fixture npm_execpath entry over an inherited case-variant", async () => { + await withInheritedExecpathVariant(async () => { + const { root, ready } = await fixture({ migrationRunner: "node-entry" }); + await ready; + expect(await readMigrationLog(root)).toBe( + "--filter @macro-tracker/db db:migrate\n", + ); + }); +}); + it("loads the Next CLI in-process when standalone output is absent", async () => { const { child, ready } = await fixture({ standalone: false }); const server = await ready;