Download src/acp/client.process.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 9.43 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/acp/client.process.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/acp/client.process.test.ts
-
curl -L -o client.process.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/acp/client.process.test.ts
9.43 kB
| /** Real-process lifecycle tests for the interactive ACP client. */ | |
| import { readFile, writeFile } from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { afterEach, describe, expect, it } from "vitest"; | |
| import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts"; | |
| import { createBoundedChildOutput } from "../../test/helpers/bounded-child-output.js"; | |
| import { createFixtureLifetime } from "../../test/helpers/fixture-lifetime.js"; | |
| import { DEFAULT_VITEST_TEST_TIMEOUT_MS } from "../../test/vitest/vitest.timeouts.js"; | |
| import { resolveTestNodeExecPath } from "../test-utils/node-process.js"; | |
| import { runAcpClientInteractive } from "./client.js"; | |
| const fixture = createFixtureLifetime(); | |
| const node = resolveTestNodeExecPath(); | |
| type ServerMode = | |
| | "handshake-failure" | |
| | "SIGTERM" | |
| | "SIGKILL" | |
| | "exit-0" | |
| | "exit-7" | |
| | "quit" | |
| | "quit-7" | |
| | "eof" | |
| | "eof-pending"; | |
| afterEach(async () => { | |
| await fixture.cleanup(); | |
| }); | |
| async function createServerFixture(mode: ServerMode) { | |
| const dir = fixture.createTempDir("openclaw-acp-client-process-test-"); | |
| const pidFile = path.join(dir, "server.pid"); | |
| const termFile = path.join(dir, "server.term"); | |
| const promptFile = path.join(dir, "server.prompt"); | |
| await writeFile(path.join(dir, "package.json"), '{"type":"commonjs"}\n'); | |
| // The client prepends "acp" to server args, so this becomes `node acp`. | |
| await writeFile( | |
| path.join(dir, "acp"), | |
| ` | |
| const fs = require("node:fs"); | |
| const readline = require("node:readline"); | |
| const mode = ${JSON.stringify(mode)}; | |
| fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); | |
| let pendingPromptReply; | |
| if (mode === "handshake-failure" || mode === "quit-7" || mode === "eof-pending") { | |
| process.on("SIGTERM", () => { | |
| fs.writeFileSync(${JSON.stringify(termFile)}, "SIGTERM"); | |
| if (mode === "quit-7") process.exit(7); | |
| pendingPromptReply?.(); | |
| pendingPromptReply = undefined; | |
| }); | |
| } | |
| const lines = readline.createInterface({ input: process.stdin }); | |
| lines.on("line", (line) => { | |
| const request = JSON.parse(line); | |
| const reply = (payload) => process.stdout.write(JSON.stringify({ | |
| jsonrpc: "2.0", id: request.id, ...payload, | |
| }) + "\\n"); | |
| if (request.method === "initialize") { | |
| reply({ result: { | |
| protocolVersion: request.params.protocolVersion, | |
| agentCapabilities: { loadSession: false }, | |
| } }); | |
| } else if (request.method === "session/new") { | |
| reply(mode === "handshake-failure" ? { error: { | |
| code: -32000, | |
| message: "fixture newSession failure", | |
| data: { stage: "newSession" }, | |
| } } : { result: { sessionId: "process-status-fixture" } }); | |
| } else if (request.method === "session/prompt") { | |
| fs.writeFileSync(${JSON.stringify(promptFile)}, request.params.prompt[0].text); | |
| if (mode === "SIGTERM" || mode === "SIGKILL") process.kill(process.pid, mode); | |
| else if (mode === "exit-0" || mode === "exit-7") process.exit(mode === "exit-7" ? 7 : 0); | |
| else if (mode === "eof-pending") { | |
| pendingPromptReply = () => reply({ result: { stopReason: "end_turn" } }); | |
| process.stdout.write(JSON.stringify({ | |
| jsonrpc: "2.0", method: "session/update", params: { | |
| sessionId: "process-status-fixture", | |
| update: { sessionUpdate: "agent_message_chunk", content: { | |
| type: "text", text: "fixture awaiting terminal EOF", | |
| } }, | |
| }, | |
| }) + "\\n"); | |
| } else reply({ result: { stopReason: "end_turn" } }); | |
| } | |
| }); | |
| setInterval(() => {}, 60_000); | |
| `, | |
| ); | |
| return { dir, pidFile, termFile, promptFile }; | |
| } | |
| describe("runAcpClientInteractive process lifecycle", () => { | |
| it( | |
| "force-kills the spawned ACP server when the handshake fails", | |
| async () => | |
| fixture.run(async () => { | |
| const { dir, pidFile, termFile } = await createServerFixture("handshake-failure"); | |
| let serverPid: number | undefined; | |
| try { | |
| const error = await runAcpClientInteractive({ | |
| serverCommand: process.execPath, | |
| cwd: dir, | |
| }).catch((caught: unknown) => caught); | |
| serverPid = Number(await readFile(pidFile, "utf8")); | |
| expect(error).toMatchObject({ | |
| name: "RequestError", | |
| code: -32000, | |
| message: "fixture newSession failure", | |
| data: { stage: "newSession" }, | |
| }); | |
| if (process.platform !== "win32") { | |
| expect(await readFile(termFile, "utf8")).toBe("SIGTERM"); | |
| } | |
| expect(() => process.kill(serverPid as number, 0)).toThrow(); | |
| } finally { | |
| if (serverPid !== undefined) { | |
| try { | |
| process.kill(serverPid, "SIGKILL"); | |
| } catch { | |
| // Already reaped. | |
| } | |
| } | |
| } | |
| }), | |
| 20_000, | |
| ); | |
| it.skipIf(process.platform === "win32").each([ | |
| { mode: "SIGTERM", code: 1, diagnostic: "signal SIGTERM" }, | |
| { mode: "SIGKILL", code: 1, diagnostic: "signal SIGKILL" }, | |
| { mode: "exit-0", code: 0, diagnostic: "code 0" }, | |
| { mode: "exit-7", code: 7, diagnostic: "code 7" }, | |
| { mode: "quit", code: 0, diagnostic: "" }, | |
| { mode: "quit-7", code: 7, diagnostic: "code 7" }, | |
| { mode: "eof", code: 0, diagnostic: "signal SIGTERM" }, | |
| { mode: "eof-pending", code: 0, diagnostic: "signal SIGKILL" }, | |
| ] as const)("preserves the client outcome for $mode", async ({ mode, code, diagnostic }) => | |
| fixture.run(async () => { | |
| const { dir, pidFile, termFile, promptFile } = await createServerFixture(mode); | |
| const stdout = createBoundedChildOutput(); | |
| const stderr = createBoundedChildOutput(); | |
| const cancellation = new AbortController(); | |
| let prompted = false; | |
| let quitSent = false; | |
| let stdinFinished = false; | |
| let native: { code: number | null; signal: NodeJS.Signals | null } | undefined; | |
| const result = await runManagedCommand({ | |
| bin: node, | |
| args: [ | |
| "--import", | |
| fileURLToPath(new URL("../../scripts/tsx.mjs", import.meta.url)), | |
| "--input-type=module", | |
| "--eval", | |
| `import { runAcpClientInteractive } from ${JSON.stringify(new URL("./client.ts", import.meta.url).href)}; | |
| await runAcpClientInteractive({ serverCommand: process.execPath, cwd: process.argv[1] });`, | |
| dir, | |
| ], | |
| env: { | |
| PATH: process.env.PATH, | |
| HOME: dir, | |
| USERPROFILE: dir, | |
| OPENCLAW_STATE_DIR: path.join(dir, "state"), | |
| }, | |
| shell: false, | |
| stdio: ["pipe", "pipe", "pipe"], | |
| requireProcessTreeExit: true, | |
| // Leave the shared test deadline enough time to join cancellation and output. | |
| timeoutMs: DEFAULT_VITEST_TEST_TIMEOUT_MS - 20_000, | |
| signal: cancellation.signal, | |
| onReady(child) { | |
| child.once("exit", (childCode, signal) => { | |
| native = { code: childCode, signal }; | |
| }); | |
| child.stderr!.on("data", stderr.append); | |
| child.stdin!.on("error", (error) => cancellation.abort(error)); | |
| child.stdin!.once("finish", () => { | |
| stdinFinished = true; | |
| }); | |
| child.stdout!.on("data", (chunk) => { | |
| stdout.append(chunk); | |
| const output = stdout.text(); | |
| if ( | |
| !prompted && | |
| output.includes("Session: process-status-fixture") && | |
| output.includes("> ") | |
| ) { | |
| prompted = true; | |
| child.stdin!.write("status marker\n"); | |
| } else if ( | |
| (mode.startsWith("quit") || mode.startsWith("eof")) && | |
| !quitSent && | |
| (mode === "eof-pending" | |
| ? output.includes("fixture awaiting terminal EOF") | |
| : /\[end_turn\][\s\S]*> /.test(output)) | |
| ) { | |
| quitSent = true; | |
| if (mode.startsWith("eof")) { | |
| child.stdin!.end(); | |
| } else { | |
| child.stdin!.write("quit\n"); | |
| } | |
| } | |
| }); | |
| }, | |
| }).catch((error: unknown) => { | |
| throw new Error( | |
| `ACP client process failed: ${JSON.stringify({ | |
| prompted, | |
| quitSent, | |
| stdinFinished, | |
| pendingPromptObserved: stdout.text().includes("fixture awaiting terminal EOF"), | |
| responseObserved: stdout.text().includes("[end_turn]"), | |
| native, | |
| })}\nstdout:\n${stdout.text()}\nstderr:\n${stderr.text()}`, | |
| { cause: error }, | |
| ); | |
| }); | |
| expect(prompted, stderr.text()).toBe(true); | |
| expect(await readFile(promptFile, "utf8")).toBe("status marker"); | |
| expect(result, `${stderr.text()}\n${stdout.text()}`).toBe(code); | |
| expect(native).toEqual({ code, signal: null }); | |
| expect(stdout.text()).toContain(`Agent exited with ${diagnostic}`); | |
| if (mode.startsWith("quit") || mode.startsWith("eof")) { | |
| expect(quitSent).toBe(true); | |
| } | |
| if (mode === "quit-7" || mode === "eof-pending") { | |
| expect(await readFile(termFile, "utf8")).toBe("SIGTERM"); | |
| } | |
| if (mode === "eof-pending") { | |
| expect(stdout.text()).toContain("[end_turn]"); | |
| expect(stdout.text().match(/> /g)).toHaveLength(1); | |
| } | |
| const serverPid = Number(await readFile(pidFile, "utf8")); | |
| expect(() => process.kill(serverPid, 0)).toThrow(); | |
| }), | |
| ); | |
| }); | |