| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. | 1 | // @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches. |
| 2 | import * as NodeChildProcess from "node:child_process"; | | |
| 3 | import * as NodeFS from "node:fs"; | 2 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeOS from "node:os"; | 3 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; | 4 | import * as NodePath from "node:path"; |
| ... | @@ -124,6 +123,50 @@ it.effect("launches the default browser through the platform command", () => { | ... | @@ -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 | it.effect("launches an installed editor with platform-safe arguments", () => | 170 | it.effect("launches an installed editor with platform-safe arguments", () => |
| 128 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 129 | const fileSystem = yield* FileSystem.FileSystem; | 172 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => | ... | @@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => |
| 163 | ); | 206 | ); |
| 164 | assert.deepEqual(spawned.args, []); | 207 | assert.deepEqual(spawned.args, []); |
| 165 | assert.equal(spawned.options.shell, true); | 208 | assert.equal(spawned.options.shell, true); |
| | 209 | assert.equal(spawned.options.detached, true); |
| 166 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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,6 +335,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", |
| 291 | assert.ok(spawned); | 335 | assert.ok(spawned); |
| 292 | assert.equal(spawned.command, "open"); | 336 | assert.equal(spawned.command, "open"); |
| 293 | assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); | 337 | assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); |
| | 338 | assert.equal(spawned.options.detached, true); |
| 294 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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,65 +394,95 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => |
| 349 | "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", | 394 | "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", |
| 350 | ); | 395 | ); |
| 351 | assert.equal(spawned.options.shell, false); | 396 | assert.equal(spawned.options.shell, false); |
| | 397 | assert.equal(spawned.options.detached, false); |
| 352 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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 | 401 | // Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the |
| 356 | // PowerShell source the reveal launch encodes, against a stub that records | 402 | // destination application is replaced, so tests never open Explorer or a browser. |
| 357 | // the raw argument tail it receives, and asserts a spaced path arrives as the | 403 | for (const kind of ["browser", "reveal"] as const) { |
| 358 | // single `/select,"<path>"` switch. Mock argv assertions cannot prove this — | 404 | it.live.skipIf(!windowsHost)( |
| 359 | // only Windows' own PowerShell -> CreateProcess quoting chain can, so the | 405 | `completes a ${kind} launch through real PowerShell after the launcher scope closes`, |
| 360 | // test runs only where that chain exists. | 406 | () => |
| 361 | // oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. | 407 | Effect.gen(function* () { |
| 362 | it.skipIf(process.platform !== "win32")( | 408 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); |
| 363 | "delivers the raw /select switch for spaced paths through real PowerShell", | 409 | const outputPath = NodePath.join(tempDir, "argv.txt"); |
| 364 | { timeout: 60_000 }, | 410 | const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) }); |
| 365 | async () => { | 411 | try { |
| 366 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); | 412 | const recorderPath = NodePath.join(tempDir, "recorder.cmd"); |
| 367 | try { | 413 | const stagingPath = NodePath.join(tempDir, "argv.tmp"); |
| 368 | const recorderPath = NodePath.join(tempDir, "recorder.cmd"); | 414 | NodeFS.writeFileSync( |
| 369 | const outputPath = NodePath.join(tempDir, "argv.txt"); | 415 | recorderPath, |
| 370 | NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); | 416 | `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`, |
| 371 | | 417 | ); |
| 372 | const target = "C:/workspace with spaces/media/author's clip.mp4"; | 418 | // The rename publishes a complete result; no sleeps or file polling. |
| 373 | const explorerTarget = target.replaceAll("/", "\\"); | 419 | const recorded = new Promise<string>((resolve, reject) => { |
| 374 | const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource( | 420 | watcher.on("change", () => { |
| 375 | recorderPath, | 421 | if (NodeFS.existsSync(outputPath)) { |
| 376 | explorerTarget, | 422 | resolve(NodeFS.readFileSync(outputPath, "utf8").trim()); |
| 377 | ); | 423 | } |
| 378 | const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; | 424 | }); |
| 379 | NodeChildProcess.execFileSync( | 425 | watcher.on("error", reject); |
| 380 | powerShellPath, | 426 | watcher.on("close", () => |
| 381 | [ | 427 | reject(new Error("PowerShell did not run the recording stub")), |
| 382 | "-NoProfile", | 428 | ); |
| 383 | "-NonInteractive", | 429 | }); |
| 384 | "-ExecutionPolicy", | 430 | |
| 385 | "Bypass", | 431 | const target = "C:/workspace with spaces/media/author's clip.mp4"; |
| 386 | "-EncodedCommand", | 432 | yield* Effect.all( |
| 387 | Buffer.from(source, "utf16le").toString("base64"), | 433 | [ |
| 388 | ], | 434 | Effect.gen(function* () { |
| 389 | { timeout: 30_000 }, | 435 | const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 390 | ); | 436 | const recordingSpawner = ChildProcessSpawner.make((command) => { |
| 391 | | 437 | assert.ok(ChildProcess.isStandardCommand(command)); |
| 392 | // Start-Process returns before the recorder runs; wait for its output. | 438 | const source = Buffer.from(command.args.at(-1) ?? "", "base64") |
| 393 | // The waits run outside the Effect runtime on purpose: the test | 439 | .toString("utf16le") |
| 394 | // exercises the real Windows process chain in real time. | 440 | .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`); |
| 395 | // @effect-diagnostics-next-line globalTimers:off | 441 | return realSpawner.spawn( |
| 396 | const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); | 442 | ChildProcess.make( |
| 397 | // @effect-diagnostics-next-line globalDate:off | 443 | command.command, |
| 398 | const deadline = Date.now() + 20_000; | 444 | [ |
| 399 | // @effect-diagnostics-next-line globalDate:off | 445 | ...command.args.slice(0, -1), |
| 400 | while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { | 446 | Buffer.from(source, "utf16le").toString("base64"), |
| 401 | await sleep(100); | 447 | ], |
| 402 | } | 448 | command.options, |
| 403 | await sleep(200); | 449 | ), |
| 404 | const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); | 450 | ); |
| 405 | assert.equal(recorded, `/select,"${explorerTarget}"`); | 451 | }); |
| 406 | } finally { | 452 | const launcher = yield* ExternalLauncher.make.pipe( |
| 407 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | 453 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner), |
| 408 | } | 454 | ); |
| 409 | }, | 455 | if (kind === "browser") { |
| 410 | ); | 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 | it.effect("does not advertise reveal on Windows when PowerShell is missing", () => | 487 | it.effect("does not advertise reveal on Windows when PowerShell is missing", () => |
| 413 | Effect.gen(function* () { | 488 | Effect.gen(function* () { |
| ... | @@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)( | ... | @@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)( |
| 488 | // The reveal routes through interop PowerShell so Explorer receives its | 563 | // The reveal routes through interop PowerShell so Explorer receives its |
| 489 | // raw `/select,"<path>"` switch even for spaced paths. | 564 | // raw `/select,"<path>"` switch even for spaced paths. |
| 490 | assert.equal(spawned.command, "powershell.exe"); | 565 | assert.equal(spawned.command, "powershell.exe"); |
| | 566 | assert.equal(spawned.options.detached, true); |
| 491 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; | 567 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; |
| 492 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); | 568 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); |
| 493 | assert.equal( | 569 | assert.equal( |