| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | | // @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. |
| 2 | | import * as NodeChildProcess from "node:child_process"; |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches. |
| 3 | 2 | import * as NodeFS from "node:fs"; |
| 4 | 3 | import * as NodeOS from "node:os"; |
| 5 | 4 | import * as NodePath from "node:path"; |
| ... | ... | @@ -124,6 +123,50 @@ it.effect("launches the default browser through the platform command", () => { |
| 124 | 123 | ); |
| 125 | 124 | }); |
| 126 | 125 | |
| 126 | it.effect.each(["win32", "linux"] as const)( |
| 127 | "launches the browser through PowerShell on %s", |
| 128 | (platform) => { |
| 129 | let spawned: ChildProcess.StandardCommand | undefined; |
| 130 | return Effect.gen(function* () { |
| 131 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 132 | |
| 133 | yield* launcher.launchBrowser("https://example.com/some path"); |
| 134 | |
| 135 | assert.ok(spawned); |
| 136 | assert.equal( |
| 137 | spawned.command, |
| 138 | platform === "win32" |
| 139 | ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" |
| 140 | : "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", |
| 141 | ); |
| 142 | assert.deepEqual(spawned.args.slice(0, -1), [ |
| 143 | "-NoProfile", |
| 144 | "-NonInteractive", |
| 145 | "-ExecutionPolicy", |
| 146 | "Bypass", |
| 147 | "-EncodedCommand", |
| 148 | ]); |
| 149 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; |
| 150 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); |
| 151 | assert.equal( |
| 152 | decodedCommand, |
| 153 | "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'", |
| 154 | ); |
| 155 | assert.equal(spawned.options.detached, platform !== "win32"); |
| 156 | }).pipe( |
| 157 | Effect.provide( |
| 158 | layerTest({ |
| 159 | platform, |
| 160 | env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" }, |
| 161 | onSpawn: (command) => { |
| 162 | spawned = command; |
| 163 | }, |
| 164 | }), |
| 165 | ), |
| 166 | ); |
| 167 | }, |
| 168 | ); |
| 169 | |
| 127 | 170 | it.effect("launches an installed editor with platform-safe arguments", () => |
| 128 | 171 | Effect.gen(function* () { |
| 129 | 172 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => |
| 163 | 206 | ); |
| 164 | 207 | assert.deepEqual(spawned.args, []); |
| 165 | 208 | assert.equal(spawned.options.shell, true); |
| 209 | assert.equal(spawned.options.detached, true); |
| 166 | 210 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 167 | 211 | ); |
| 168 | 212 | |
| ... | ... | @@ -291,6 +335,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", |
| 291 | 335 | assert.ok(spawned); |
| 292 | 336 | assert.equal(spawned.command, "open"); |
| 293 | 337 | assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); |
| 338 | assert.equal(spawned.options.detached, true); |
| 294 | 339 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 295 | 340 | ); |
| 296 | 341 | |
| ... | ... | @@ -349,65 +394,95 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => |
| 349 | 394 | "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", |
| 350 | 395 | ); |
| 351 | 396 | assert.equal(spawned.options.shell, false); |
| 397 | assert.equal(spawned.options.detached, false); |
| 352 | 398 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 353 | 399 | ); |
| 354 | 400 | |
| 355 | | // Real-chain smoke check for the Explorer selection contract: runs the exact |
| 356 | | // PowerShell source the reveal launch encodes, against a stub that records |
| 357 | | // the raw argument tail it receives, and asserts a spaced path arrives as the |
| 358 | | // single `/select,"<path>"` switch. Mock argv assertions cannot prove this — |
| 359 | | // only Windows' own PowerShell -> CreateProcess quoting chain can, so the |
| 360 | | // test runs only where that chain exists. |
| 361 | | // oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. |
| 362 | | it.skipIf(process.platform !== "win32")( |
| 363 | | "delivers the raw /select switch for spaced paths through real PowerShell", |
| 364 | | { timeout: 60_000 }, |
| 365 | | async () => { |
| 366 | | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); |
| 367 | | try { |
| 368 | | const recorderPath = NodePath.join(tempDir, "recorder.cmd"); |
| 369 | | const outputPath = NodePath.join(tempDir, "argv.txt"); |
| 370 | | NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); |
| 371 | | |
| 372 | | const target = "C:/workspace with spaces/media/author's clip.mp4"; |
| 373 | | const explorerTarget = target.replaceAll("/", "\\"); |
| 374 | | const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource( |
| 375 | | recorderPath, |
| 376 | | explorerTarget, |
| 377 | | ); |
| 378 | | const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; |
| 379 | | NodeChildProcess.execFileSync( |
| 380 | | powerShellPath, |
| 381 | | [ |
| 382 | | "-NoProfile", |
| 383 | | "-NonInteractive", |
| 384 | | "-ExecutionPolicy", |
| 385 | | "Bypass", |
| 386 | | "-EncodedCommand", |
| 387 | | Buffer.from(source, "utf16le").toString("base64"), |
| 388 | | ], |
| 389 | | { timeout: 30_000 }, |
| 390 | | ); |
| 391 | | |
| 392 | | // Start-Process returns before the recorder runs; wait for its output. |
| 393 | | // The waits run outside the Effect runtime on purpose: the test |
| 394 | | // exercises the real Windows process chain in real time. |
| 395 | | // @effect-diagnostics-next-line globalTimers:off |
| 396 | | const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); |
| 397 | | // @effect-diagnostics-next-line globalDate:off |
| 398 | | const deadline = Date.now() + 20_000; |
| 399 | | // @effect-diagnostics-next-line globalDate:off |
| 400 | | while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { |
| 401 | | await sleep(100); |
| 402 | | } |
| 403 | | await sleep(200); |
| 404 | | const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); |
| 405 | | assert.equal(recorded, `/select,"${explorerTarget}"`); |
| 406 | | } finally { |
| 407 | | NodeFS.rmSync(tempDir, { recursive: true, force: true }); |
| 408 | | } |
| 409 | | }, |
| 410 | | ); |
| 401 | // Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the |
| 402 | // destination application is replaced, so tests never open Explorer or a browser. |
| 403 | for (const kind of ["browser", "reveal"] as const) { |
| 404 | it.live.skipIf(!windowsHost)( |
| 405 | `completes a ${kind} launch through real PowerShell after the launcher scope closes`, |
| 406 | () => |
| 407 | Effect.gen(function* () { |
| 408 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); |
| 409 | const outputPath = NodePath.join(tempDir, "argv.txt"); |
| 410 | const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) }); |
| 411 | try { |
| 412 | const recorderPath = NodePath.join(tempDir, "recorder.cmd"); |
| 413 | const stagingPath = NodePath.join(tempDir, "argv.tmp"); |
| 414 | NodeFS.writeFileSync( |
| 415 | recorderPath, |
| 416 | `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`, |
| 417 | ); |
| 418 | // The rename publishes a complete result; no sleeps or file polling. |
| 419 | const recorded = new Promise<string>((resolve, reject) => { |
| 420 | watcher.on("change", () => { |
| 421 | if (NodeFS.existsSync(outputPath)) { |
| 422 | resolve(NodeFS.readFileSync(outputPath, "utf8").trim()); |
| 423 | } |
| 424 | }); |
| 425 | watcher.on("error", reject); |
| 426 | watcher.on("close", () => |
| 427 | reject(new Error("PowerShell did not run the recording stub")), |
| 428 | ); |
| 429 | }); |
| 430 | |
| 431 | const target = "C:/workspace with spaces/media/author's clip.mp4"; |
| 432 | yield* Effect.all( |
| 433 | [ |
| 434 | Effect.gen(function* () { |
| 435 | const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 436 | const recordingSpawner = ChildProcessSpawner.make((command) => { |
| 437 | assert.ok(ChildProcess.isStandardCommand(command)); |
| 438 | const source = Buffer.from(command.args.at(-1) ?? "", "base64") |
| 439 | .toString("utf16le") |
| 440 | .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`); |
| 441 | return realSpawner.spawn( |
| 442 | ChildProcess.make( |
| 443 | command.command, |
| 444 | [ |
| 445 | ...command.args.slice(0, -1), |
| 446 | Buffer.from(source, "utf16le").toString("base64"), |
| 447 | ], |
| 448 | command.options, |
| 449 | ), |
| 450 | ); |
| 451 | }); |
| 452 | const launcher = yield* ExternalLauncher.make.pipe( |
| 453 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner), |
| 454 | ); |
| 455 | if (kind === "browser") { |
| 456 | yield* launcher.launchBrowser(recorderPath); |
| 457 | } else { |
| 458 | yield* launcher.launchEditor({ |
| 459 | editor: "file-manager", |
| 460 | cwd: target, |
| 461 | reveal: true, |
| 462 | }); |
| 463 | } |
| 464 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 465 | Effect.promise(() => |
| 466 | recorded.then((value) => { |
| 467 | assert.equal( |
| 468 | value, |
| 469 | kind === "browser" ? "" : `/select,"${target.replaceAll("/", "\\")}"`, |
| 470 | ); |
| 471 | }), |
| 472 | ), |
| 473 | ], |
| 474 | { concurrency: "unbounded" }, |
| 475 | ); |
| 476 | } finally { |
| 477 | watcher.close(); |
| 478 | // The recording stub's cmd.exe can still hold the directory for a |
| 479 | // moment after it publishes its output, which Windows reports as EPERM. |
| 480 | NodeFS.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); |
| 481 | } |
| 482 | }), |
| 483 | { timeout: 30_000 }, |
| 484 | ); |
| 485 | } |
| 411 | 486 | |
| 412 | 487 | it.effect("does not advertise reveal on Windows when PowerShell is missing", () => |
| 413 | 488 | Effect.gen(function* () { |
| ... | ... | @@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)( |
| 488 | 563 | // The reveal routes through interop PowerShell so Explorer receives its |
| 489 | 564 | // raw `/select,"<path>"` switch even for spaced paths. |
| 490 | 565 | assert.equal(spawned.command, "powershell.exe"); |
| 566 | assert.equal(spawned.options.detached, true); |
| 491 | 567 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; |
| 492 | 568 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); |
| 493 | 569 | assert.equal( |