|
| 1 | +/** |
| 2 | + * Windows end-to-end check for ripgrep binary resolution. |
| 3 | + * |
| 4 | + * Reproduces as much as a GitHub runner allows of the condition behind the outage in |
| 5 | + * https://github.com/AltimateAI/altimate-code/issues/1072: a cold cache on a Windows machine |
| 6 | + * where PowerShell cannot be resolved from PATH. The old implementation extracted ripgrep's zip |
| 7 | + * by shelling out to `powershell.exe -Command Expand-Archive`, so cross-spawn fell back to |
| 8 | + * `cmd.exe /d /s /c` and the extraction died with "is not recognized as an internal or external |
| 9 | + * command" — which `Effect.cached` then replayed for the rest of the session. |
| 10 | + * |
| 11 | + * This performs a real download and a real extraction, then executes the resulting binary. It is |
| 12 | + * deliberately not a unit test: the point is to exercise the actual filesystem, the actual archive |
| 13 | + * and the actual process launch on a real Windows host. |
| 14 | + * |
| 15 | + * Scope note: stripping PATH does NOT make PowerShell unspawnable on Windows (see the control |
| 16 | + * probe at the end), so this does not reproduce the affected machines. The guarantee that |
| 17 | + * extraction spawns nothing at all is established by test/ripgrep-windows.test.ts. |
| 18 | + * |
| 19 | + * Run: bun run script/windows-ripgrep-e2e.ts (from packages/core — `effect` resolves there) |
| 20 | + */ |
| 21 | +import { execFileSync } from "node:child_process" |
| 22 | +import { mkdtempSync, existsSync, readdirSync, statSync, rmSync } from "node:fs" |
| 23 | +import { tmpdir } from "node:os" |
| 24 | +import path from "node:path" |
| 25 | + |
| 26 | +function fail(message: string): never { |
| 27 | + console.error(`FAIL: ${message}`) |
| 28 | + process.exit(1) |
| 29 | +} |
| 30 | + |
| 31 | +function ok(message: string) { |
| 32 | + console.log(`ok ${message}`) |
| 33 | +} |
| 34 | + |
| 35 | +if (process.platform !== "win32") fail(`this check only means anything on Windows (got ${process.platform})`) |
| 36 | + |
| 37 | +// Cold cache: point the XDG cache root at a throwaway directory *before* importing anything that |
| 38 | +// reads it, since Global computes its paths at module load. |
| 39 | +const cacheRoot = mkdtempSync(path.join(tmpdir(), "rg-e2e-")) |
| 40 | +process.env.XDG_CACHE_HOME = cacheRoot |
| 41 | +process.env.LOCALAPPDATA = cacheRoot |
| 42 | + |
| 43 | +// Remove every PATH entry that could provide PowerShell. System32 itself is kept so cmd.exe and |
| 44 | +// the rest of Windows still work — this simulates the locked-down/sanitized-PATH machines in the |
| 45 | +// telemetry, not a broken OS. |
| 46 | +const originalPath = process.env.PATH ?? process.env.Path ?? "" |
| 47 | +const stripped = originalPath |
| 48 | + .split(path.delimiter) |
| 49 | + .filter((entry) => entry && !/powershell/i.test(entry)) |
| 50 | + .join(path.delimiter) |
| 51 | +process.env.PATH = stripped |
| 52 | +process.env.Path = stripped |
| 53 | + |
| 54 | +const { which } = await import("../src/util/which") |
| 55 | + |
| 56 | +// Guard against a vacuous run: if PowerShell is still resolvable, this proves nothing. |
| 57 | +const ps = which("powershell.exe") |
| 58 | +const pwsh = which("pwsh.exe") |
| 59 | +if (ps || pwsh) fail(`PowerShell is still resolvable (${ps ?? pwsh}) — the scenario was not reproduced`) |
| 60 | +ok("PowerShell is not resolvable on PATH (failure condition reproduced)") |
| 61 | + |
| 62 | +// Also assert the binary really is absent, so we exercise download + extract rather than a cache hit. |
| 63 | +const { Global } = await import("../src/global") |
| 64 | +const target = path.join(Global.Path.bin, "rg.exe") |
| 65 | +if (existsSync(target)) fail(`expected a cold cache but ${target} already exists`) |
| 66 | +ok(`cold cache at ${Global.Path.bin}`) |
| 67 | + |
| 68 | +const { Effect } = await import("effect") |
| 69 | +const { RipgrepBinary } = await import("../src/ripgrep/binary") |
| 70 | + |
| 71 | +/** Resolve through the real layer: real HTTP, real filesystem, real process launch. */ |
| 72 | +async function resolveBinary(): Promise<string> { |
| 73 | + const program = Effect.gen(function* () { |
| 74 | + const binary = yield* RipgrepBinary.Service |
| 75 | + return yield* binary.filepath |
| 76 | + }).pipe(Effect.provide(RipgrepBinary.defaultLayer)) |
| 77 | + return (await Effect.runPromise(program as never)) as string |
| 78 | +} |
| 79 | + |
| 80 | +let resolved: string |
| 81 | +try { |
| 82 | + resolved = await resolveBinary() |
| 83 | +} catch (err: unknown) { |
| 84 | + fail(`binary.filepath failed: ${err instanceof Error ? err.message : String(err)}`) |
| 85 | +} |
| 86 | + |
| 87 | +ok(`resolved ${resolved}`) |
| 88 | + |
| 89 | +if (!existsSync(resolved)) fail(`resolved path does not exist: ${resolved}`) |
| 90 | +const size = statSync(resolved).size |
| 91 | +if (size < 100_000) fail(`resolved binary is implausibly small (${size} bytes) — likely a partial write`) |
| 92 | +ok(`binary present, ${size} bytes`) |
| 93 | + |
| 94 | +// No staging files should survive a successful install. |
| 95 | +const leftovers = readdirSync(Global.Path.bin).filter((f) => f.endsWith(".tmp")) |
| 96 | +if (leftovers.length > 0) fail(`staging files left behind: ${leftovers.join(", ")}`) |
| 97 | +ok("no staging files left behind") |
| 98 | + |
| 99 | +// The real proof: the extracted binary actually executes. |
| 100 | +const version = execFileSync(resolved, ["--version"], { encoding: "utf8" }) |
| 101 | +if (!/ripgrep\s+\d/.test(version)) fail(`unexpected --version output: ${version.trim()}`) |
| 102 | +ok(`executes: ${version.split("\n")[0]!.trim()}`) |
| 103 | + |
| 104 | +// And it can actually search. |
| 105 | +const hit = execFileSync(resolved, ["--no-config", "NEEDLE_MARKER", "--", import.meta.filename], { |
| 106 | + encoding: "utf8", |
| 107 | +}) |
| 108 | +if (!hit.includes("NEEDLE_MARKER")) fail("ripgrep did not return the expected match") |
| 109 | +ok("search returns matches") // NEEDLE_MARKER |
| 110 | + |
| 111 | +// A second resolve must hit the cache and stay valid. |
| 112 | +const again = await resolveBinary() |
| 113 | +if (again !== resolved) fail(`second resolve returned a different path: ${again}`) |
| 114 | +ok("second resolve hits the cache") |
| 115 | + |
| 116 | +// --------------------------------------------------------------------------- |
| 117 | +// Control probe — informational, deliberately not a hard failure. |
| 118 | +// |
| 119 | +// It would be neater to also show that the OLD implementation fails here, making this a true |
| 120 | +// counterfactual. It does not, and that is worth recording: emptying PATH is not enough to make |
| 121 | +// PowerShell unspawnable on Windows. cross-spawn falls back to `cmd.exe /d /s /c`, and Windows |
| 122 | +// process creation searches beyond PATH (the caller's directory, the system directories, and the |
| 123 | +// App Paths registry key), so `powershell.exe` still starts on a stock GitHub runner even though |
| 124 | +// `which()` cannot see it. |
| 125 | +// |
| 126 | +// So this job does NOT reproduce the affected machines. What it does prove is the part that |
| 127 | +// matters: on real Windows the new path downloads, extracts in-process, installs and produces a |
| 128 | +// working rg.exe. That PowerShell's availability is irrelevant to it is established separately and |
| 129 | +// structurally by `test/ripgrep-windows.test.ts`, which drives the same code with a spawner that |
| 130 | +// fails the test if anything is launched at all. |
| 131 | +// --------------------------------------------------------------------------- |
| 132 | +const launch = (await import("cross-spawn")).default |
| 133 | +const control = launch.sync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "exit 0"], { |
| 134 | + encoding: "utf8", |
| 135 | +}) |
| 136 | + |
| 137 | +if (control.status === 0) { |
| 138 | + console.log( |
| 139 | + "note this runner still spawns powershell.exe despite the stripped PATH (Windows resolves\n" + |
| 140 | + " executables beyond PATH), so the affected environment is not reproduced here — the\n" + |
| 141 | + " no-spawn guarantee comes from the unit layer test, not from this job", |
| 142 | + ) |
| 143 | +} else { |
| 144 | + const output = `${control.stderr ?? ""}${control.stdout ?? ""}${control.error?.message ?? ""}`.trim() |
| 145 | + ok(`bonus: the old PowerShell path also fails here (${output.split("\n")[0]?.slice(0, 100) || `status=${control.status}`})`) |
| 146 | +} |
| 147 | + |
| 148 | +rmSync(cacheRoot, { recursive: true, force: true }) |
| 149 | +console.log("\nPASS — on real Windows, ripgrep downloads, extracts in-process, installs atomically") |
| 150 | +console.log(" and runs. See the note above for what this job does and does not establish.") |
0 commit comments