fix: replace node-pty with child_process.spawn + force-tty preload

node-pty's posix_spawnp is blocked in sandboxed environments.
child_process.spawn works everywhere. force-tty.cjs patches
stdout.isTTY so fixtures behave as if running in a real terminal.

Also fix setRawMode this-binding bug (acquireRawMode on undefined).

72/82 PTY tests now pass. Remaining 10 need timing/assertion fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 23:55:52 +08:00
parent 2f7babd258
commit dd4c5aa3e1
4 changed files with 55 additions and 32 deletions
@@ -0,0 +1,6 @@
// Preload script: make stdout/stderr look like TTY for child_process.spawn
// This allows subprocess fixture tests to run without node-pty.
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
Object.defineProperty(process.stderr, "isTTY", { value: true, writable: true });
if (!process.stdout.columns) process.stdout.columns = 100;
if (!process.stdout.rows) process.stdout.rows = 24;
@@ -1,11 +1,8 @@
import process from "node:process";
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import path from "node:path";
import url from "node:url";
const require = createRequire(import.meta.url);
const { spawn } = require("node-pty") as typeof import("node-pty");
const fixturesDir = url.fileURLToPath(new URL("../fixtures", import.meta.url));
type RunProps = { env?: Record<string, string>; columns?: number };
@@ -19,25 +16,38 @@ export const run = async (fixture: string, props?: RunProps): Promise<string> =>
...props?.env,
};
if (props?.columns !== undefined) {
env["COLUMNS"] = String(props.columns);
}
return new Promise<string>((resolve, reject) => {
const term = spawn("node", ["--import=tsx", path.join(fixturesDir, `${fixture}.tsx`)], {
name: "xterm-color",
cols: props?.columns ?? 100,
const forceTty = url.fileURLToPath(new URL("./force-tty.cjs", import.meta.url));
const child = spawn("node", ["--require", forceTty, "--import=tsx", path.join(fixturesDir, `${fixture}.tsx`)], {
cwd: fixturesDir,
env,
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
term.onData((data) => {
output += data;
child.stdout!.on("data", (data: Buffer) => {
output += data.toString();
});
term.onExit(({ exitCode }) => {
child.stderr!.on("data", (data: Buffer) => {
const text = data.toString();
// Filter Vue slot warnings from fixtures using tsx jsx transform
if (!text.includes("[Vue warn]: Non-function value encountered for default slot")) {
output += text;
}
});
child.on("exit", (exitCode) => {
if (exitCode === 0) {
resolve(output);
return;
}
reject(new Error(`Process exited with non-zero code: ${exitCode}`));
reject(new Error(`Process exited with non-zero code: ${exitCode}\n${output}`));
});
child.on("error", (err) => {
reject(err);
});
});
};
@@ -1,17 +1,13 @@
import process from "node:process";
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import path from "node:path";
import url from "node:url";
const require = createRequire(import.meta.url);
const { spawn } = require("node-pty") as typeof import("node-pty");
const fixturesDir = url.fileURLToPath(new URL("../fixtures", import.meta.url));
const term = (fixture: string, args: string[] = []) => {
let resolve: (value?: unknown) => void;
let reject: (error?: Error) => void;
const exitPromise = new Promise((res, rej) => {
resolve = res;
reject = rej;
@@ -29,40 +25,49 @@ const term = (fixture: string, args: string[] = []) => {
FORCE_COLOR: "3",
};
const ps = spawn("node", ["--import=tsx", path.join(fixturesDir, `${fixture}.tsx`), ...args], {
name: "xterm-color",
cols: 100,
cwd: fixturesDir,
env,
});
const forceTty = url.fileURLToPath(new URL("./force-tty.cjs", import.meta.url));
const child = spawn(
"node",
["--require", forceTty, "--import=tsx", path.join(fixturesDir, `${fixture}.tsx`), ...args],
{ cwd: fixturesDir, env, stdio: ["pipe", "pipe", "pipe"] },
);
const result = {
write(input: string) {
void readyPromise.then(() => {
ps.write(input);
child.stdin!.write(input);
});
},
output: "",
waitForExit: async () => exitPromise,
};
ps.onData((data) => {
result.output += data;
child.stdout!.on("data", (data: Buffer) => {
result.output += data.toString();
if (result.output.includes("__READY__")) {
readyResolve();
}
});
ps.onExit(({ exitCode }) => {
child.stderr!.on("data", (data: Buffer) => {
const text = data.toString();
if (!text.includes("[Vue warn]: Non-function value encountered for default slot")) {
result.output += text;
}
});
child.on("exit", (exitCode) => {
if (exitCode === 0) {
resolve();
return;
}
reject(new Error(`Process exited with non-zero exit code: ${exitCode}`));
});
child.on("error", (err) => {
reject(err);
});
return result;
};
+5 -3
View File
@@ -862,13 +862,13 @@ function createStdinController(
let localRefs = 0;
return {
const controller: StdinController = {
stdin,
setRawMode(mode: boolean) {
if (mode) {
this.acquireRawMode();
controller.acquireRawMode();
} else {
this.releaseRawMode();
controller.releaseRawMode();
}
},
isRawModeSupported: appCtx.isRawModeSupported,
@@ -944,4 +944,6 @@ function createStdinController(
}
},
};
return controller;
}