Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 132 additions & 58 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs.
import * as NodeChildProcess from "node:child_process";
// @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -121,6 +120,50 @@ it.effect("launches the default browser through the platform command", () => {
);
});

it.effect.each(["win32", "linux"] as const)(
"launches the browser through PowerShell on %s",
(platform) => {
let spawned: ChildProcess.StandardCommand | undefined;
return Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;

yield* launcher.launchBrowser("https://example.com/some path");

assert.ok(spawned);
assert.equal(
spawned.command,
platform === "win32"
? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
: "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
);
assert.deepEqual(spawned.args.slice(0, -1), [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
]);
const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
assert.equal(
decodedCommand,
"$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'",
);
assert.equal(spawned.options.detached, platform !== "win32");
}).pipe(
Effect.provide(
testLayer({
platform,
env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" },
onSpawn: (command) => {
spawned = command;
},
}),
),
);
},
);

it.effect("launches an installed editor with platform-safe arguments", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -156,6 +199,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
'^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
]);
assert.equal(spawned.options.shell, true);
assert.equal(spawned.options.detached, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

Expand Down Expand Up @@ -280,6 +324,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS",
assert.ok(spawned);
assert.equal(spawned.command, "open");
assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);
assert.equal(spawned.options.detached, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

Expand Down Expand Up @@ -338,65 +383,93 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
"$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",
);
assert.equal(spawned.options.shell, false);
assert.equal(spawned.options.detached, false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// Real-chain smoke check for the Explorer selection contract: runs the exact
// PowerShell source the reveal launch encodes, against a stub that records
// the raw argument tail it receives, and asserts a spaced path arrives as the
// single `/select,"<path>"` switch. Mock argv assertions cannot prove this —
// only Windows' own PowerShell -> CreateProcess quoting chain can, so the
// test runs only where that chain exists.
// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime.
it.skipIf(process.platform !== "win32")(
"delivers the raw /select switch for spaced paths through real PowerShell",
{ timeout: 60_000 },
async () => {
const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
try {
const recorderPath = NodePath.join(tempDir, "recorder.cmd");
const outputPath = NodePath.join(tempDir, "argv.txt");
NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`);

const target = "C:/workspace with spaces/media/author's clip.mp4";
const explorerTarget = target.replaceAll("/", "\\");
const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(
recorderPath,
explorerTarget,
);
const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
NodeChildProcess.execFileSync(
powerShellPath,
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
Buffer.from(source, "utf16le").toString("base64"),
],
{ timeout: 30_000 },
);

// Start-Process returns before the recorder runs; wait for its output.
// The waits run outside the Effect runtime on purpose: the test
// exercises the real Windows process chain in real time.
// @effect-diagnostics-next-line globalTimers:off
const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis));
// @effect-diagnostics-next-line globalDate:off
const deadline = Date.now() + 20_000;
// @effect-diagnostics-next-line globalDate:off
while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) {
await sleep(100);
}
await sleep(200);
const recorded = NodeFS.readFileSync(outputPath, "utf8").trim();
assert.equal(recorded, `/select,"${explorerTarget}"`);
} finally {
NodeFS.rmSync(tempDir, { recursive: true, force: true });
}
},
);
// Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the
// destination application is replaced, so tests never open Explorer or a browser.
for (const kind of ["browser", "reveal"] as const) {
it.live.skipIf(!windowsHost)(
`completes a ${kind} launch through real PowerShell after the launcher scope closes`,
() =>
Effect.gen(function* () {
const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
const outputPath = NodePath.join(tempDir, "argv.txt");
const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) });
try {
const recorderPath = NodePath.join(tempDir, "recorder.cmd");
const stagingPath = NodePath.join(tempDir, "argv.tmp");
NodeFS.writeFileSync(
recorderPath,
`@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`,
);
// The rename publishes a complete result; no sleeps or file polling.
const recorded = new Promise<string>((resolve, reject) => {
watcher.on("change", () => {
if (NodeFS.existsSync(outputPath)) {
resolve(NodeFS.readFileSync(outputPath, "utf8").trim());
}
});
watcher.on("error", reject);
watcher.on("close", () =>
reject(new Error("PowerShell did not run the recording stub")),
);
});

const target = "C:/workspace with spaces/media/author's clip.mp4";
yield* Effect.all(
[
Effect.gen(function* () {
const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const recordingSpawner = ChildProcessSpawner.make((command) => {
assert.ok(ChildProcess.isStandardCommand(command));
const source = Buffer.from(command.args.at(-1) ?? "", "base64")
.toString("utf16le")
.replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`);
return realSpawner.spawn(
ChildProcess.make(
command.command,
[
...command.args.slice(0, -1),
Buffer.from(source, "utf16le").toString("base64"),
],
command.options,
),
);
});
const launcher = yield* ExternalLauncher.make.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner),
);
if (kind === "browser") {
yield* launcher.launchBrowser(recorderPath);
} else {
yield* launcher.launchEditor({
editor: "file-manager",
cwd: target,
reveal: true,
});
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
Effect.promise(() =>
recorded.then((value) => {
assert.equal(
value,
kind === "browser" ? "" : `/select,"${target.replaceAll("/", "\\")}"`,
);
}),
),
],
{ concurrency: "unbounded" },
);
} finally {
watcher.close();
NodeFS.rmSync(tempDir, { recursive: true, force: true });
}
}),
{ timeout: 30_000 },
);
}

it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -477,6 +550,7 @@ it.effect.skipIf(windowsHost)(
// The reveal routes through interop PowerShell so Explorer receives its
// raw `/select,"<path>"` switch even for spaced paths.
assert.equal(spawned.command, "powershell.exe");
assert.equal(spawned.options.detached, true);
const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
assert.equal(
Expand Down
30 changes: 18 additions & 12 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ function escapePowerShellStringLiteral(input: string): string {
return `'${input.replaceAll("'", "''")}'`;
}

function isWindowsPowerShellCommand(command: string): boolean {
return /(^|[\\/])powershell\.exe$/i.test(command);
}

function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string {
return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
}
Expand Down Expand Up @@ -206,15 +210,21 @@ function shouldUseWindowsHostFromWsl(
);
}

function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch {
function resolveWindowsBrowserLaunch(
target: string,
command: string,
platform: NodeJS.Platform,
): ProcessLaunch {
const encodedCommand = encodeUtf16LeBase64(
`$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
);
return {
command,
args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
options: {
detached: true,
// Native Windows PowerShell can exit before executing a detached launch.
// WSL uses Linux process-session semantics and keeps its existing behavior.
detached: platform !== "win32",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
shell: false,
stdin: "ignore",
stdout: "ignore",
Expand Down Expand Up @@ -394,11 +404,11 @@ function buildBrowserLaunch(
}

if (platform === "win32") {
return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));
return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env), platform);
}

if (shouldUseWindowsHostFromWsl(platform, env)) {
return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());
return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath(), platform);
}

return {
Expand Down Expand Up @@ -575,14 +585,8 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
* when the path has spaces and Explorer misparses it, silently opening a
* fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1
* reaches the child's command line verbatim, preserving the raw switch.
*
* Exported so the Windows smoke test can drive the identical source through a
* real PowerShell against a recording stub instead of Explorer.
*/
export function buildFileExplorerRevealPowerShellSource(
explorerCommand: string,
target: string,
): string {
function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string {
return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
}

Expand Down Expand Up @@ -723,12 +727,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
}

const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
const platform = yield* HostProcessPlatform;
yield* launchAndUnref(
{
command: spawnCommand.command,
args: spawnCommand.args,
options: {
detached: true,
// Match the browser launch workaround without changing editor shims or WSL.
detached: platform !== "win32" || !isWindowsPowerShellCommand(spawnCommand.command),
shell: spawnCommand.shell,
stdin: "ignore",
stdout: "ignore",
Expand Down
Loading