Skip to content

Commit 9e2b283

Browse files
umair-ablyclaude
andcommitted
test(e2e): cover non-TTY 0x03 Ctrl+C path; share interactive harness
Hoist the spawn/output-capture/waitFor boilerplate into a single `startInteractive()` helper used by all four interactive integration tests. Replace the mid-command 0x03 test with the reachable at-prompt case. A 0x03 (ETX) byte cannot interrupt a *running* command over piped stdin: handleCommand calls rl.pause() during execution, which pauses the stdin stream so the process.stdin "data" handler never receives the byte. The at-prompt branch (emit SIGINT to readline -> "^C" + hint) is the part that actually works in non-TTY mode. Mid-command interruption stays covered by the real-SIGINT test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ab2eb7 commit 9e2b283

1 file changed

Lines changed: 82 additions & 60 deletions

File tree

test/e2e/interactive/interactive-integration.test.ts

Lines changed: 82 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { describe, it, expect } from "vitest";
2-
import { spawn } from "node:child_process";
2+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
33
import * as path from "node:path";
44
import { fileURLToPath } from "node:url";
55

66
const __filename = fileURLToPath(import.meta.url);
77
const __dirname = path.dirname(__filename);
8+
const binPath = path.join(__dirname, "../../../bin/run.js");
89

910
/**
1011
* Integration tests for `ably interactive` running as a single process (no bash
@@ -16,68 +17,69 @@ const __dirname = path.dirname(__filename);
1617
* behaviour and terminal-state/EIO assertions live in
1718
* test/tty/commands/interactive-sigint.test.ts (run with `pnpm test:tty`).
1819
*/
19-
describe("Interactive Mode - in-process integration", () => {
20-
const binPath = path.join(__dirname, "../../../bin/run.js");
2120

22-
it("starts and exits cleanly via `exit`", { timeout: 30000 }, async () => {
23-
const proc = spawn("node", [binPath, "interactive"], {
24-
stdio: "pipe",
25-
env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" },
26-
});
21+
interface InteractiveProc {
22+
proc: ChildProcessWithoutNullStreams;
23+
/** Resolves once `substr` appears in combined stdout+stderr, else rejects. */
24+
waitFor: (substr: string, timeoutMs: number) => Promise<void>;
25+
getOutput: () => string;
26+
hasExited: () => boolean;
27+
}
28+
29+
function startInteractive(
30+
extraEnv: Record<string, string> = {},
31+
): InteractiveProc {
32+
const proc = spawn("node", [binPath, "interactive"], {
33+
stdio: "pipe",
34+
env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1", ...extraEnv },
35+
});
2736

28-
let output = "";
29-
proc.stdout.on("data", (d) => (output += d.toString()));
37+
let output = "";
38+
let exited = false;
39+
proc.stdout.on("data", (d) => (output += d.toString()));
40+
proc.stderr.on("data", (d) => (output += d.toString()));
41+
proc.on("exit", () => (exited = true));
3042

31-
await new Promise<void>((resolve) => {
43+
const waitFor = (substr: string, timeoutMs: number) =>
44+
new Promise<void>((resolve, reject) => {
45+
const start = Date.now();
3246
const check = setInterval(() => {
33-
if (output.includes("ably>")) {
47+
if (output.includes(substr)) {
3448
clearInterval(check);
3549
resolve();
50+
} else if (exited) {
51+
clearInterval(check);
52+
reject(new Error(`process exited before "${substr}"`));
53+
} else if (Date.now() - start > timeoutMs) {
54+
clearInterval(check);
55+
reject(new Error(`timeout waiting for "${substr}"`));
3656
}
3757
}, 100);
3858
});
3959

60+
return { proc, waitFor, getOutput: () => output, hasExited: () => exited };
61+
}
62+
63+
describe("Interactive Mode - in-process integration", () => {
64+
it("starts and exits cleanly via `exit`", { timeout: 30000 }, async () => {
65+
const { proc, waitFor, getOutput } = startInteractive();
66+
67+
await waitFor("ably>", 8000);
4068
proc.stdin.write("exit\n");
4169

4270
const exitCode = await new Promise<number>((resolve) => {
4371
proc.on("exit", (code) => resolve(code ?? 0));
4472
});
4573

4674
expect(exitCode).toBe(0);
47-
expect(output).toContain("Goodbye!");
75+
expect(getOutput()).toContain("Goodbye!");
4876
});
4977

5078
it(
5179
"interrupts a running command via SIGINT and stays alive in the same process",
5280
{ timeout: 30000 },
5381
async () => {
54-
const proc = spawn("node", [binPath, "interactive"], {
55-
stdio: "pipe",
56-
env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" },
57-
});
58-
59-
let output = "";
60-
let exited = false;
61-
proc.stdout.on("data", (d) => (output += d.toString()));
62-
proc.stderr.on("data", (d) => (output += d.toString()));
63-
proc.on("exit", () => (exited = true));
64-
65-
const waitFor = (substr: string, timeoutMs: number) =>
66-
new Promise<void>((resolve, reject) => {
67-
const start = Date.now();
68-
const check = setInterval(() => {
69-
if (output.includes(substr)) {
70-
clearInterval(check);
71-
resolve();
72-
} else if (exited) {
73-
clearInterval(check);
74-
reject(new Error(`process exited before "${substr}"`));
75-
} else if (Date.now() - start > timeoutMs) {
76-
clearInterval(check);
77-
reject(new Error(`timeout waiting for "${substr}"`));
78-
}
79-
}, 100);
80-
});
82+
const { proc, waitFor, getOutput, hasExited } = startInteractive();
8183

8284
await waitFor("ably>", 8000);
8385

@@ -88,43 +90,63 @@ describe("Interactive Mode - in-process integration", () => {
8890

8991
// The process must NOT exit; it should re-prompt and still run commands.
9092
await new Promise((r) => setTimeout(r, 500));
91-
expect(exited).toBe(false);
93+
expect(hasExited()).toBe(false);
9294

9395
proc.stdin.write("version\n");
9496
await waitFor("Version:", 6000);
9597

9698
proc.stdin.write("exit\n");
9799
await new Promise<void>((resolve) => proc.on("exit", () => resolve()));
98100

99-
expect(output).not.toMatch(/setRawMode EIO|Terminal state corrupted/);
101+
expect(getOutput()).not.toMatch(
102+
/setRawMode EIO|Terminal state corrupted/,
103+
);
100104
},
101105
);
102106

103107
it(
104-
"emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)",
108+
"treats a 0x03 byte at the prompt as Ctrl+C and stays alive (non-TTY data handler)",
105109
{ timeout: 30000 },
106110
async () => {
107-
const proc = spawn("node", [binPath, "interactive"], {
108-
stdio: "pipe",
109-
env: {
110-
...process.env,
111-
ABLY_SUPPRESS_WELCOME: "1",
112-
ABLY_WRAPPER_MODE: "1",
113-
},
114-
});
111+
// In non-TTY mode readline does NOT turn a 0x03 (ETX) byte into a SIGINT,
112+
// so the stdin data handler in interactive.ts does it. At an idle prompt
113+
// it emits SIGINT to readline, which prints `^C` and a hint to type
114+
// `exit` (it does NOT kill the shell). Note: this only covers the
115+
// at-prompt branch — the running-command branch cannot be exercised over
116+
// piped stdin because readline pauses the stream during command
117+
// execution, so a 0x03 byte never reaches the handler then. Mid-command
118+
// interruption is covered by the real-SIGINT test above (the path a TTY
119+
// and the node-pty-backed web CLI actually use).
120+
const { proc, waitFor, getOutput, hasExited } = startInteractive();
115121

116-
let output = "";
117-
proc.stdout.on("data", (d) => (output += d.toString()));
122+
await waitFor("ably>", 8000);
123+
124+
// Deliver Ctrl+C as the ETX byte over stdin rather than as an OS signal.
125+
proc.stdin.write(Buffer.from([0x03]));
126+
await waitFor("Signal received", 6000);
127+
expect(hasExited()).toBe(false);
118128

119-
await new Promise<void>((resolve) => {
120-
const check = setInterval(() => {
121-
if (output.includes("ably>")) {
122-
clearInterval(check);
123-
resolve();
124-
}
125-
}, 100);
129+
// The shell is still usable.
130+
proc.stdin.write("exit\n");
131+
const exitCode = await new Promise<number>((resolve) => {
132+
proc.on("exit", (code) => resolve(code ?? 0));
126133
});
127134

135+
expect(exitCode).toBe(0);
136+
expect(getOutput()).toContain("Goodbye!");
137+
expect(getOutput()).not.toMatch(
138+
/setRawMode EIO|Terminal state corrupted/,
139+
);
140+
},
141+
);
142+
143+
it(
144+
"emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)",
145+
{ timeout: 30000 },
146+
async () => {
147+
const { proc, waitFor } = startInteractive({ ABLY_WRAPPER_MODE: "1" });
148+
149+
await waitFor("ably>", 8000);
128150
proc.stdin.write("exit\n");
129151

130152
const exitCode = await new Promise<number>((resolve) => {

0 commit comments

Comments
 (0)