mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 23:30:42 +00:00
* Harden Windows command wrapper resolution * clawsweeper: route Windows cmd.exe wrapper through getWindowsInstallRoots Replace the local SystemRoot/windir/SYSTEMROOT/WINDIR scan in resolveTrustedWindowsCmdExe with the shared getWindowsInstallRoots() resolver from src/infra/windows-install-roots.ts. The shared resolver already rejects UNC paths, root-relative values, semicolon-delimited path-lists, and missing-drive-letter roots, and prefers registry-derived roots over env, so the wrapper-launch trust boundary now matches the existing Windows install-root boundary on main. Tests: - _resetWindowsInstallRootsForTests in beforeEach so cached roots track per-test process.env mutations - expectedTrustedCmdExe helper now joins the resolved systemRoot, so the expected wrapper executable matches the production resolver on Linux CI (where it falls back to DEFAULT_WINDOWS_SYSTEM_ROOT) - new "rejects unsafe Windows root values" test covers UNC, semicolon-delimited path-list, root-relative, and bare-relative SystemRoot inputs * Add CHANGELOG entry for #77472 Windows command wrapper hardening * clawsweeper: stub registry probe in Windows wrapper tests On real Windows CI runners getWindowsInstallRoots() reads the canonical SystemRoot from the registry (e.g. C:\WINDOWS) before falling back to process.env, which shadowed the env-only setup in the ComSpec-poisoning and unsafe-root tests and produced casing mismatches like "C:\WINDOWS\System32\cmd.exe" vs the expected "C:\Windows\...". Pass a queryRegistryValue stub returning null in beforeEach (and inside the unsafe-root loop) so install-root resolution is fully driven by the test's process.env setup on every platform. * clawsweeper: overwrite WINDIR alongside SystemRoot in unsafe-root test Real Windows runners did not honor `delete process.env.windir`, so the unsafe-root iteration's WINDIR fallback still resolved to the canonical `C:\WINDOWS` and produced a casing mismatch against the expected default `C:\Windows\System32\cmd.exe`. Set both `SystemRoot` and `WINDIR` to the unsafe payload so every install-root env source is rejected by `normalizeWindowsInstallRoot` and the resolver falls through to `DEFAULT_WINDOWS_SYSTEM_ROOT`.
572 lines
20 KiB
TypeScript
572 lines
20 KiB
TypeScript
import type { execFile as execFileType } from "node:child_process";
|
||
import { EventEmitter } from "node:events";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import {
|
||
_resetWindowsInstallRootsForTests,
|
||
getWindowsInstallRoots,
|
||
} from "../infra/windows-install-roots.js";
|
||
|
||
const { spawnMock, spawnSyncMock, execFileMock, execFilePromisifyMock } = vi.hoisted(() => {
|
||
const execFilePromisifyMock = vi.fn();
|
||
const execFileMock = Object.assign(vi.fn(), {
|
||
[Symbol.for("nodejs.util.promisify.custom")]: execFilePromisifyMock,
|
||
__promisify__: execFilePromisifyMock,
|
||
});
|
||
return {
|
||
spawnMock: vi.fn(),
|
||
spawnSyncMock: vi.fn(),
|
||
execFileMock,
|
||
execFilePromisifyMock,
|
||
};
|
||
});
|
||
|
||
vi.mock("node:child_process", async () => {
|
||
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
|
||
return mockNodeBuiltinModule(
|
||
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
|
||
{
|
||
spawn: spawnMock,
|
||
spawnSync: spawnSyncMock,
|
||
execFile: execFileMock as unknown as typeof execFileType,
|
||
},
|
||
);
|
||
});
|
||
|
||
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
|
||
let runExec: typeof import("./exec.js").runExec;
|
||
|
||
type MockChild = EventEmitter & {
|
||
exitCode?: number | null;
|
||
signalCode?: NodeJS.Signals | null;
|
||
stdout: EventEmitter;
|
||
stderr: EventEmitter;
|
||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
|
||
kill: ReturnType<typeof vi.fn>;
|
||
pid?: number;
|
||
killed?: boolean;
|
||
};
|
||
|
||
function createMockChild(params?: {
|
||
closeCode?: number | null;
|
||
closeSignal?: NodeJS.Signals | null;
|
||
exitCode?: number | null;
|
||
exitCodeAfterClose?: number | null;
|
||
exitCodeAfterCloseDelayMs?: number;
|
||
signal?: NodeJS.Signals | null;
|
||
autoClose?: boolean;
|
||
}): MockChild {
|
||
const child = new EventEmitter() as MockChild;
|
||
child.stdout = new EventEmitter();
|
||
child.stderr = new EventEmitter();
|
||
child.exitCode = params?.exitCode ?? params?.closeCode ?? 0;
|
||
child.signalCode = params?.signal ?? null;
|
||
child.stdin = {
|
||
write: vi.fn(),
|
||
end: vi.fn(),
|
||
};
|
||
child.kill = vi.fn(() => true);
|
||
child.pid = 1234;
|
||
child.killed = false;
|
||
if (params?.autoClose !== false) {
|
||
queueMicrotask(() => {
|
||
child.emit("close", params?.closeCode ?? 0, params?.closeSignal ?? params?.signal ?? null);
|
||
if (params?.exitCodeAfterClose !== undefined) {
|
||
setTimeout(() => {
|
||
child.exitCode = params.exitCodeAfterClose ?? null;
|
||
}, params.exitCodeAfterCloseDelayMs ?? 0);
|
||
}
|
||
});
|
||
}
|
||
return child;
|
||
}
|
||
|
||
type SpawnCall = [string, string[], Record<string, unknown>];
|
||
|
||
type ExecCall = [
|
||
string,
|
||
string[],
|
||
Record<string, unknown>,
|
||
(err: Error | null, stdout: string, stderr: string) => void,
|
||
];
|
||
|
||
function expectCmdWrappedInvocation(params: {
|
||
captured: SpawnCall | ExecCall | undefined;
|
||
expectedComSpec: string;
|
||
}) {
|
||
if (!params.captured) {
|
||
throw new Error("expected command wrapper to be called");
|
||
}
|
||
expect(params.captured[0]).toBe(params.expectedComSpec);
|
||
expect(params.captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
|
||
expect(params.captured[1][3]).toContain("pnpm.cmd --version");
|
||
expect(params.captured[2].windowsHide).toBe(true);
|
||
expect(params.captured[2].windowsVerbatimArguments).toBe(true);
|
||
}
|
||
|
||
function expectedTrustedCmdExe(): string {
|
||
return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
|
||
}
|
||
|
||
async function expectShimmedWindowsCommandWithoutExitCodeSucceeds(params?: { killed?: boolean }) {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const child = createMockChild({
|
||
closeCode: null,
|
||
exitCode: null,
|
||
});
|
||
child.killed = params?.killed ?? false;
|
||
|
||
spawnMock.mockImplementation(() => child);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
expect(result.signal).toBeNull();
|
||
expect(result.termination).toBe("exit");
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
}
|
||
|
||
describe("windows command wrapper behavior", () => {
|
||
beforeAll(async () => {
|
||
({ runCommandWithTimeout, runExec } = await import("./exec.js"));
|
||
});
|
||
|
||
beforeEach(() => {
|
||
// Stub the registry probe so install-root resolution is fully driven by
|
||
// process.env in tests; on real Windows runners the registry returns the
|
||
// canonical SystemRoot and would shadow the test's env setup.
|
||
_resetWindowsInstallRootsForTests({ queryRegistryValue: () => null });
|
||
spawnMock.mockReset();
|
||
spawnSyncMock.mockReset();
|
||
spawnSyncMock.mockReturnValue({ stdout: "Active code page: 936", stderr: "" });
|
||
execFileMock.mockReset();
|
||
execFilePromisifyMock.mockReset();
|
||
execFilePromisifyMock.mockImplementation(
|
||
(command: string, args: string[], options: Record<string, unknown>) =>
|
||
new Promise((resolve, reject) => {
|
||
execFileMock(
|
||
command,
|
||
args,
|
||
options,
|
||
(err: Error | null, stdout: string | Buffer, stderr: string | Buffer) => {
|
||
if (err) {
|
||
reject(err);
|
||
return;
|
||
}
|
||
resolve({ stdout, stderr });
|
||
},
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("wraps .cmd commands via cmd.exe in runCommandWithTimeout", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const expectedComSpec = expectedTrustedCmdExe();
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
expectCmdWrappedInvocation({ captured, expectedComSpec });
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("ignores ComSpec when selecting the Windows command wrapper", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const previousComSpec = process.env.ComSpec;
|
||
const previousSystemRoot = process.env.SystemRoot;
|
||
process.env.ComSpec = "C:\\workspace\\evil\\cmd.exe";
|
||
process.env.SystemRoot = "C:\\Windows";
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
expectCmdWrappedInvocation({
|
||
captured,
|
||
expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"),
|
||
});
|
||
} finally {
|
||
if (previousComSpec === undefined) {
|
||
delete process.env.ComSpec;
|
||
} else {
|
||
process.env.ComSpec = previousComSpec;
|
||
}
|
||
if (previousSystemRoot === undefined) {
|
||
delete process.env.SystemRoot;
|
||
} else {
|
||
process.env.SystemRoot = previousSystemRoot;
|
||
}
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("rejects unsafe Windows root values when selecting the command wrapper", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const previousSystemRoot = process.env.SystemRoot;
|
||
const previousWindir = process.env.WINDIR;
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
for (const unsafeRoot of [
|
||
"\\\\evil\\share",
|
||
"C:\\Windows;C:\\evil",
|
||
"\\Windows",
|
||
"relative\\path",
|
||
]) {
|
||
_resetWindowsInstallRootsForTests({ queryRegistryValue: () => null });
|
||
// Set every install-root env source to the unsafe value so the
|
||
// resolver rejects each one and falls through to the safe default.
|
||
// Deleting WINDIR here is unreliable on real Windows runners, so
|
||
// overwrite it with the same rejected payload.
|
||
process.env.SystemRoot = unsafeRoot;
|
||
process.env.WINDIR = unsafeRoot;
|
||
spawnMock.mockClear();
|
||
|
||
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
expectCmdWrappedInvocation({
|
||
captured,
|
||
expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"),
|
||
});
|
||
}
|
||
} finally {
|
||
if (previousSystemRoot === undefined) {
|
||
delete process.env.SystemRoot;
|
||
} else {
|
||
process.env.SystemRoot = previousSystemRoot;
|
||
}
|
||
if (previousWindir === undefined) {
|
||
delete process.env.WINDIR;
|
||
} else {
|
||
process.env.WINDIR = previousWindir;
|
||
}
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("wraps corepack.cmd via cmd.exe in runCommandWithTimeout", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const expectedComSpec = expectedTrustedCmdExe();
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["corepack", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
if (!captured) {
|
||
throw new Error("expected corepack shim spawn");
|
||
}
|
||
expect(captured[0]).toBe(expectedComSpec);
|
||
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
|
||
expect(captured[1][3]).toContain("corepack.cmd --version");
|
||
expect(captured[2].windowsHide).toBe(true);
|
||
expect(captured[2].windowsVerbatimArguments).toBe(true);
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("keeps child exitCode when close reports null on Windows npm shims", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const child = createMockChild({ closeCode: null, exitCode: 0 });
|
||
|
||
spawnMock.mockImplementation(() => child);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("spawns node + npm-cli.js for npm argv to avoid direct .cmd execution", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
||
const child = createMockChild({ closeCode: 0, exitCode: 0 });
|
||
|
||
spawnMock.mockImplementation(() => child);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
if (!captured) {
|
||
throw new Error("expected npm shim spawn");
|
||
}
|
||
expect(captured[0]).toBe(process.execPath);
|
||
expect(captured[1][0]).toBe(
|
||
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
||
);
|
||
expect(captured[1][1]).toBe("--version");
|
||
expect(captured[2].windowsHide).toBe(true);
|
||
expect(captured[2].windowsVerbatimArguments).toBeUndefined();
|
||
expect(captured[2].stdio).toEqual(["inherit", "pipe", "pipe"]);
|
||
} finally {
|
||
existsSpy.mockRestore();
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("falls back to npm.cmd when npm-cli.js is unavailable", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||
const expectedComSpec = expectedTrustedCmdExe();
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
if (!captured) {
|
||
throw new Error("expected npm.cmd fallback spawn");
|
||
}
|
||
expect(captured[0]).toBe(expectedComSpec);
|
||
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
|
||
expect(captured[1][3]).toContain("npm.cmd --version");
|
||
expect(captured[2].windowsHide).toBe(true);
|
||
expect(captured[2].windowsVerbatimArguments).toBe(true);
|
||
expect(captured[2].stdio).toEqual(["inherit", "pipe", "pipe"]);
|
||
} finally {
|
||
existsSpy.mockRestore();
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("waits for Windows exitCode settlement after close reports null", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const child = createMockChild({
|
||
closeCode: null,
|
||
exitCode: null,
|
||
exitCodeAfterClose: 0,
|
||
exitCodeAfterCloseDelayMs: 50,
|
||
});
|
||
|
||
spawnMock.mockImplementation(() => child);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("treats shimmed Windows commands without a reported exit code as success when they close cleanly", async () => {
|
||
await expectShimmedWindowsCommandWithoutExitCodeSucceeds();
|
||
});
|
||
|
||
it("treats shimmed Windows commands without a reported exit code as success even when child.killed is true", async () => {
|
||
await expectShimmedWindowsCommandWithoutExitCodeSucceeds({ killed: true });
|
||
});
|
||
|
||
it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const expectedComSpec = expectedTrustedCmdExe();
|
||
|
||
execFileMock.mockImplementation(
|
||
(
|
||
_command: string,
|
||
_args: string[],
|
||
_options: Record<string, unknown>,
|
||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||
) => {
|
||
cb(null, "ok", "");
|
||
},
|
||
);
|
||
|
||
try {
|
||
await runExec("pnpm", ["--version"], 1000);
|
||
const captured = execFileMock.mock.calls[0] as ExecCall | undefined;
|
||
expectCmdWrappedInvocation({ captured, expectedComSpec });
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("sets windowsHide on direct runExec invocations too", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
|
||
execFileMock.mockImplementation(
|
||
(
|
||
_command: string,
|
||
_args: string[],
|
||
_options: Record<string, unknown>,
|
||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||
) => {
|
||
cb(null, "ok", "");
|
||
},
|
||
);
|
||
|
||
try {
|
||
await runExec("node", ["--version"], 1000);
|
||
const captured = execFileMock.mock.calls[0] as ExecCall | undefined;
|
||
if (!captured) {
|
||
throw new Error("expected direct execFile invocation");
|
||
}
|
||
expect(captured[0]).toBe("node");
|
||
expect(captured[1]).toEqual(["--version"]);
|
||
expect(captured[2].windowsHide).toBe(true);
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("sets windowsHide on direct runCommandWithTimeout invocations too", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
|
||
spawnMock.mockImplementation(
|
||
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
|
||
);
|
||
|
||
try {
|
||
const result = await runCommandWithTimeout(["node", "--version"], { timeoutMs: 1000 });
|
||
expect(result.code).toBe(0);
|
||
const captured = spawnMock.mock.calls[0] as SpawnCall | undefined;
|
||
if (!captured) {
|
||
throw new Error("expected direct spawn invocation");
|
||
}
|
||
expect(captured[0]).toBe("node");
|
||
expect(captured[1]).toEqual(["--version"]);
|
||
expect(captured[2].windowsHide).toBe(true);
|
||
expect(captured[2].windowsVerbatimArguments).toBeUndefined();
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("kills the Windows process tree when the overall timeout elapses", async () => {
|
||
vi.useFakeTimers();
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const child = createMockChild({ autoClose: false });
|
||
const taskkillChild = createMockChild();
|
||
|
||
spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild);
|
||
|
||
try {
|
||
const resultPromise = runCommandWithTimeout(["node", "idle.js"], { timeoutMs: 80 });
|
||
|
||
await vi.advanceTimersByTimeAsync(81);
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||
expect(spawnMock.mock.calls[1]?.[0]).toBe("taskkill");
|
||
expect(spawnMock.mock.calls[1]?.[1]).toEqual(["/PID", "1234", "/T", "/F"]);
|
||
expect(spawnMock.mock.calls[1]?.[2]).toMatchObject({
|
||
stdio: "ignore",
|
||
windowsHide: true,
|
||
});
|
||
|
||
child.emit("close", null, "SIGKILL");
|
||
const result = await resultPromise;
|
||
expect(result.termination).toBe("timeout");
|
||
expect(result.code).not.toBe(0);
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("decodes GBK stdout and stderr from runExec on Windows", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const stdout = Buffer.from([0xb2, 0xe2, 0xca, 0xd4]);
|
||
const stderr = Buffer.from([0xa3, 0xbb]);
|
||
|
||
execFileMock.mockImplementation(
|
||
(
|
||
_command: string,
|
||
_args: string[],
|
||
_options: Record<string, unknown>,
|
||
cb: (err: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||
) => {
|
||
cb(null, stdout, stderr);
|
||
},
|
||
);
|
||
|
||
try {
|
||
const result = await runExec("node", ["gbk-output.js"], 1000);
|
||
expect(result.stdout).toBe("测试");
|
||
expect(result.stderr).toBe(";");
|
||
const captured = execFileMock.mock.calls[0] as ExecCall | undefined;
|
||
expect(captured?.[2].encoding).toBe("buffer");
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("prefers valid UTF-8 stdout from runExec on Windows", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
|
||
execFileMock.mockImplementation(
|
||
(
|
||
_command: string,
|
||
_args: string[],
|
||
_options: Record<string, unknown>,
|
||
cb: (err: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||
) => {
|
||
cb(null, Buffer.from("测试", "utf8"), Buffer.alloc(0));
|
||
},
|
||
);
|
||
|
||
try {
|
||
await expect(runExec("node", ["utf8-output.js"], 1000)).resolves.toMatchObject({
|
||
stdout: "测试",
|
||
});
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("decodes spawn stdout once so GBK characters split across chunks survive", async () => {
|
||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||
const child = createMockChild({ autoClose: false });
|
||
spawnMock.mockImplementation(() => {
|
||
queueMicrotask(() => {
|
||
child.stdout.emit("data", Buffer.from([0xb2]));
|
||
child.stdout.emit("data", Buffer.from([0xe2, 0xca]));
|
||
child.stdout.emit("data", Buffer.from([0xd4]));
|
||
child.emit("close", 0, null);
|
||
});
|
||
return child;
|
||
});
|
||
|
||
try {
|
||
await expect(
|
||
runCommandWithTimeout(["node", "gbk-output.js"], { timeoutMs: 1000 }),
|
||
).resolves.toMatchObject({
|
||
stdout: "测试",
|
||
});
|
||
} finally {
|
||
platformSpy.mockRestore();
|
||
}
|
||
});
|
||
});
|