| ... | ... | @@ -2,11 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { it as effectIt } from "@effect/vitest"; |
| 3 | 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; |
| 5 | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 9 | |
| 7 | 10 | import { |
| 8 | 11 | extractPathFromShellOutput, |
| 9 | 12 | CommandAvailability, |
| 13 | CommandResolutionCache, |
| 10 | 14 | type CommandAvailabilityChecker, |
| 11 | 15 | isCommandAvailable, |
| 12 | 16 | listLoginShellCandidates, |
| ... | ... | @@ -375,6 +379,123 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { |
| 375 | 379 | expect(result._tag).toBe("Failure"); |
| 376 | 380 | }), |
| 377 | 381 | ); |
| 382 | |
| 383 | // Records every path the scan stats, without ever reporting a match, so the |
| 384 | // walk runs to exhaustion and the probe set can be inspected. Assertions |
| 385 | // below count probes rather than naming paths: `Path` is the host's, so the |
| 386 | // separator differs between a Windows and a Linux CI runner. |
| 387 | const recordProbes = (env: NodeJS.ProcessEnv) => |
| 388 | Effect.gen(function* () { |
| 389 | const probed: Array<string> = []; |
| 390 | const result = yield* resolveCommandPath("definitely-not-installed", { env }).pipe( |
| 391 | Effect.provideService(HostProcessPlatform, "win32"), |
| 392 | Effect.provideService(CommandResolutionCache, new Map()), |
| 393 | Effect.provide( |
| 394 | FileSystem.layerNoop({ |
| 395 | stat: (filePath) => |
| 396 | Effect.sync(() => { |
| 397 | probed.push(filePath); |
| 398 | return { type: "Directory" } as FileSystem.File.Info; |
| 399 | }), |
| 400 | }), |
| 401 | ), |
| 402 | Effect.result, |
| 403 | ); |
| 404 | |
| 405 | expect(result._tag).toBe("Failure"); |
| 406 | return probed; |
| 407 | }); |
| 408 | |
| 409 | it.effect("visits a repeated PATH directory only once", () => |
| 410 | Effect.gen(function* () { |
| 411 | const probed = yield* recordProbes({ |
| 412 | PATH: "C:\\bin;C:\\other;C:\\bin;C:\\other", |
| 413 | PATHEXT: ".COM;.EXE", |
| 414 | }); |
| 415 | |
| 416 | // Two directories, two extensions, upper and lowercase spellings. |
| 417 | expect(probed).toHaveLength(8); |
| 418 | expect(new Set(probed).size).toBe(probed.length); |
| 419 | }), |
| 420 | ); |
| 421 | |
| 422 | it.effect("still visits a PATH entry that differs only in case", () => |
| 423 | Effect.gen(function* () { |
| 424 | const probed = yield* recordProbes({ |
| 425 | PATH: "C:\\bin;C:\\BIN", |
| 426 | PATHEXT: ".COM;.EXE", |
| 427 | }); |
| 428 | |
| 429 | // Deliberately not folded together. Windows 10+ can mark a directory |
| 430 | // case-sensitive, so the two spellings are not provably one directory and |
| 431 | // skipping the second could hide a command that is really there. |
| 432 | expect(probed).toHaveLength(8); |
| 433 | }), |
| 434 | ); |
| 435 | |
| 436 | it.effect.each(["audit-command", "audit-command.CMD"])( |
| 437 | "resolves lowercase executable files for %s in a case-sensitive Windows PATH directory", |
| 438 | (command) => |
| 439 | Effect.gen(function* () { |
| 440 | const fs = yield* FileSystem.FileSystem; |
| 441 | const path = yield* Path.Path; |
| 442 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-case-sensitive-path-" }); |
| 443 | const executable = path.join(cwd, "audit-command.cmd"); |
| 444 | yield* fs.writeFileString(executable, "@echo off\n"); |
| 445 | |
| 446 | const resolved = yield* resolveCommandPath(command, { |
| 447 | env: { PATH: cwd, PATHEXT: ".CMD" }, |
| 448 | }).pipe( |
| 449 | Effect.provideService(HostProcessPlatform, "win32"), |
| 450 | Effect.provideService(CommandResolutionCache, new Map()), |
| 451 | Effect.provideService(FileSystem.FileSystem, { |
| 452 | ...fs, |
| 453 | // Keep this case-sensitive fixture portable to case-insensitive hosts. |
| 454 | stat: (filePath) => |
| 455 | fs.stat(filePath === executable ? filePath : path.join(cwd, "missing")), |
| 456 | }), |
| 457 | ); |
| 458 | |
| 459 | expect(resolved).toBe(executable); |
| 460 | }), |
| 461 | ); |
| 462 | |
| 463 | it.effect("keeps cached misses until expiry while allowing explicit paths", () => |
| 464 | Effect.gen(function* () { |
| 465 | const fs = yield* FileSystem.FileSystem; |
| 466 | const path = yield* Path.Path; |
| 467 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-path-cache-" }); |
| 468 | const executable = path.join(cwd, "appeared.CMD"); |
| 469 | const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } }; |
| 470 | |
| 471 | expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( |
| 472 | "Failure", |
| 473 | ); |
| 474 | yield* fs.writeFileString(executable, "@echo off\n"); |
| 475 | expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( |
| 476 | "Failure", |
| 477 | ); |
| 478 | expect(yield* resolveCommandPath(executable, options)).toBe(executable); |
| 479 | yield* TestClock.adjust("30 seconds"); |
| 480 | expect(yield* resolveCommandPath("appeared", options)).toBe(executable); |
| 481 | }).pipe( |
| 482 | Effect.provideService(HostProcessPlatform, "win32"), |
| 483 | Effect.provideService(CommandResolutionCache, new Map()), |
| 484 | ), |
| 485 | ); |
| 486 | |
| 487 | it.effect("keeps upper and lowercase PATHEXT candidates", () => |
| 488 | Effect.gen(function* () { |
| 489 | const probed = yield* recordProbes({ |
| 490 | PATH: "C:\\bin", |
| 491 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
| 492 | }); |
| 493 | |
| 494 | expect(probed).toHaveLength(8); |
| 495 | expect(probed.filter((filePath) => /\.(COM|EXE|BAT|CMD)$/.test(filePath))).toHaveLength(4); |
| 496 | expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4); |
| 497 | }), |
| 498 | ); |
| 378 | 499 | }); |
| 379 | 500 | |
| 380 | 501 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |