bridge.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import assert from "node:assert/strict";
  2. import { spawn } from "node:child_process";
  3. import type { ChildProcessWithoutNullStreams } from "node:child_process";
  4. import { once } from "node:events";
  5. import { createServer } from "node:net";
  6. import { fileURLToPath } from "node:url";
  7. import test from "node:test";
  8. import { TestWebSocketPeer } from "./websocket-peer.ts";
  9. const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
  10. const executable = fileURLToPath(
  11. new URL("../bin/codex-app-server-bridge.js", import.meta.url),
  12. );
  13. type RunningBridge = Readonly<{
  14. child: ChildProcessWithoutNullStreams;
  15. stdout: string[];
  16. stderr: string[];
  17. }>;
  18. function startBridge(port: number, arguments_: string[] = ["app-server"]): RunningBridge {
  19. const child = spawn(process.execPath, [executable, ...arguments_], {
  20. cwd: repositoryRoot,
  21. env: {
  22. ...process.env,
  23. CODEX_APP_SERVER_URL: `ws://127.0.0.1:${port}`,
  24. },
  25. stdio: ["pipe", "pipe", "pipe"],
  26. });
  27. const stdout: string[] = [];
  28. const stderr: string[] = [];
  29. child.stdout.setEncoding("utf8");
  30. child.stderr.setEncoding("utf8");
  31. child.stdout.on("data", (chunk: string) => stdout.push(chunk));
  32. child.stderr.on("data", (chunk: string) => stderr.push(chunk));
  33. return { child, stdout, stderr };
  34. }
  35. async function waitForExit(bridge: RunningBridge): Promise<{
  36. code: number | null;
  37. signal: NodeJS.Signals | null;
  38. }> {
  39. const [code, signal] = await once(bridge.child, "exit");
  40. return { code: code as number | null, signal: signal as NodeJS.Signals | null };
  41. }
  42. async function unusedPort(): Promise<number> {
  43. const server = createServer();
  44. server.listen(0, "127.0.0.1");
  45. await once(server, "listening");
  46. const address = server.address();
  47. if (address === null || typeof address === "string") {
  48. throw new Error("temporary server did not receive a TCP port");
  49. }
  50. const { port } = address;
  51. server.close();
  52. await once(server, "close");
  53. return port;
  54. }
  55. test("unsupported arguments fail without opening a socket", async (context) => {
  56. const peer = await TestWebSocketPeer.start();
  57. context.after(async () => peer.stop());
  58. for (const arguments_ of [[], ["other"], ["app-server", "extra"]]) {
  59. const bridge = startBridge(peer.port, arguments_);
  60. const result = await waitForExit(bridge);
  61. assert.equal(result.code, 1);
  62. assert.match(bridge.stderr.join(""), /Usage: codex-app-server-bridge app-server/);
  63. }
  64. assert.equal(peer.connections.length, 0);
  65. });
  66. test("startup input is preserved and duplex payload text stays ordered", async (context) => {
  67. const peer = await TestWebSocketPeer.start({ handshakeDelayMs: 100 });
  68. context.after(async () => peer.stop());
  69. const bridge = startBridge(peer.port);
  70. const initialize = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
  71. const logout = '{"jsonrpc":"2.0","id":2,"method":"account/logout","secret":"never-log-me"}';
  72. bridge.child.stdin.write(`${initialize}\n\n \n${logout}\n`);
  73. const connection = await peer.nextConnection();
  74. await once(connection, "text");
  75. if (connection.textFrames.length < 2) {
  76. await once(connection, "text");
  77. }
  78. assert.deepEqual(connection.textFrames, [initialize, logout]);
  79. const responseOne = '{"jsonrpc":"2.0","id":1,"result":{"ready":true}}';
  80. const responseTwo = '{"jsonrpc":"2.0","id":2,"result":null}';
  81. connection.sendText(responseOne);
  82. connection.sendText(responseTwo);
  83. while (!bridge.stdout.join("").includes(responseTwo)) {
  84. await once(bridge.child.stdout, "data");
  85. }
  86. assert.equal(bridge.stdout.join(""), `${responseOne}\n${responseTwo}\n`);
  87. bridge.child.stdin.end();
  88. const result = await waitForExit(bridge);
  89. assert.equal(result.code, 0);
  90. assert.equal(bridge.stderr.join(""), "");
  91. });
  92. test("binary frames are private fatal protocol errors", async (context) => {
  93. const peer = await TestWebSocketPeer.start();
  94. context.after(async () => peer.stop());
  95. const bridge = startBridge(peer.port);
  96. const connection = await peer.nextConnection();
  97. connection.sendBinary(Buffer.from("binary-secret"));
  98. const result = await waitForExit(bridge);
  99. assert.equal(result.code, 1);
  100. assert.equal(bridge.stdout.join(""), "");
  101. assert.match(bridge.stderr.join(""), /binary WebSocket frames are not supported/);
  102. assert.doesNotMatch(bridge.stderr.join(""), /binary-secret/);
  103. });
  104. test("unexpected remote closure is nonzero and does not reconnect", async (context) => {
  105. const peer = await TestWebSocketPeer.start();
  106. context.after(async () => peer.stop());
  107. const bridge = startBridge(peer.port);
  108. const connection = await peer.nextConnection();
  109. connection.close();
  110. const result = await waitForExit(bridge);
  111. assert.equal(result.code, 1);
  112. assert.match(bridge.stderr.join(""), /connection closed unexpectedly/);
  113. await new Promise((resolve) => setTimeout(resolve, 50));
  114. assert.equal(peer.connections.length, 1);
  115. });
  116. test("connection refusal is sanitized and nonzero", async () => {
  117. const port = await unusedPort();
  118. const bridge = startBridge(port);
  119. bridge.child.stdin.write('{"secret":"must-not-appear"}\n');
  120. const result = await waitForExit(bridge);
  121. assert.equal(result.code, 1);
  122. assert.match(bridge.stderr.join(""), new RegExp(`connection error for ws://127\\.0\\.0\\.1:${port}`));
  123. assert.doesNotMatch(bridge.stderr.join(""), /must-not-appear/);
  124. });
  125. test("a stalled handshake fails at the five-second startup deadline", async (context) => {
  126. const peer = await TestWebSocketPeer.start({ completeHandshake: false });
  127. context.after(async () => peer.stop());
  128. const bridge = startBridge(peer.port);
  129. const startedAt = Date.now();
  130. bridge.child.stdin.write('{"id":1,"secret":"timeout-secret"}\n');
  131. const result = await waitForExit(bridge);
  132. const elapsed = Date.now() - startedAt;
  133. assert.equal(result.code, 1);
  134. assert.ok(elapsed >= 4_800, `timeout fired too early after ${elapsed} ms`);
  135. assert.ok(elapsed < 7_000, `timeout fired too late after ${elapsed} ms`);
  136. assert.match(bridge.stderr.join(""), /startup timeout after 5000 ms/);
  137. assert.doesNotMatch(bridge.stderr.join(""), /timeout-secret/);
  138. assert.equal(peer.connections.length, 0);
  139. });
  140. test("stdin EOF stays successful when the peer aborts during local close", async (context) => {
  141. const peer = await TestWebSocketPeer.start();
  142. context.after(async () => peer.stop());
  143. const bridge = startBridge(peer.port);
  144. const connection = await peer.nextConnection();
  145. connection.abortOnCloseFrame = true;
  146. bridge.child.stdin.end();
  147. const result = await waitForExit(bridge);
  148. assert.equal(result.code, 0);
  149. assert.equal(result.signal, null);
  150. assert.equal(bridge.stderr.join(""), "");
  151. });
  152. for (const signal of ["SIGINT", "SIGTERM"] as const) {
  153. test(`${signal} closes locally with a zero exit`, async (context) => {
  154. const peer = await TestWebSocketPeer.start();
  155. context.after(async () => peer.stop());
  156. const bridge = startBridge(peer.port);
  157. const connection = await peer.nextConnection();
  158. const closeFrame = once(connection, "closeFrame");
  159. bridge.child.kill(signal);
  160. await closeFrame;
  161. const result = await waitForExit(bridge);
  162. assert.equal(result.code, 0);
  163. assert.equal(result.signal, null);
  164. assert.equal(bridge.stderr.join(""), "");
  165. });
  166. }
  167. test("two bridge processes use distinct connections and leave the peer alive", async (context) => {
  168. const peer = await TestWebSocketPeer.start();
  169. context.after(async () => peer.stop());
  170. const first = startBridge(peer.port);
  171. const second = startBridge(peer.port);
  172. first.child.stdin.write("first\n");
  173. second.child.stdin.write("second\n");
  174. while (peer.connections.length < 2) {
  175. await once(peer, "connection");
  176. }
  177. while (peer.connections.some((connection) => connection.textFrames.length === 0)) {
  178. await Promise.race(peer.connections.map(async (connection) => once(connection, "text")));
  179. }
  180. assert.equal(peer.connections.length, 2);
  181. assert.deepEqual(
  182. peer.connections.flatMap((connection) => connection.textFrames).sort(),
  183. ["first", "second"],
  184. );
  185. first.child.stdin.end();
  186. second.child.stdin.end();
  187. const [firstResult, secondResult] = await Promise.all([
  188. waitForExit(first),
  189. waitForExit(second),
  190. ]);
  191. assert.equal(firstResult.code, 0);
  192. assert.equal(secondResult.code, 0);
  193. assert.equal(peer.server.listening, true);
  194. });