| ... | @@ -2,11 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -2,11 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it as effectIt } from "@effect/vitest"; | 2 | import { it as effectIt } from "@effect/vitest"; |
| 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | import * as Effect from "effect/Effect"; | 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 | import { describe, expect, it, vi } from "vite-plus/test"; | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | | 9 | |
| 7 | import { | 10 | import { |
| 8 | extractPathFromShellOutput, | 11 | extractPathFromShellOutput, |
| 9 | CommandAvailability, | 12 | CommandAvailability, |
| | 13 | CommandResolutionCache, |
| 10 | type CommandAvailabilityChecker, | 14 | type CommandAvailabilityChecker, |
| 11 | isCommandAvailable, | 15 | isCommandAvailable, |
| 12 | listLoginShellCandidates, | 16 | listLoginShellCandidates, |
| ... | @@ -375,6 +379,123 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { | ... | @@ -375,6 +379,123 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { |
| 375 | expect(result._tag).toBe("Failure"); | 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 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { | 501 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |