Skip to content

Commit 19e6f15

Browse files
committed
test(google-meet): prove command bridge stream errors
1 parent d7837a5 commit 19e6f15

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Google Meet realtime tests cover real local command-pair substitute processes.
2+
import { spawn as spawnChildProcess, type ChildProcess } from "node:child_process";
3+
import { once } from "node:events";
4+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5+
import { tmpdir } from "node:os";
6+
import path from "node:path";
7+
import type { RealtimeTranscriptionProviderPlugin } from "openclaw/plugin-sdk/realtime-transcription";
8+
import { afterEach, describe, expect, it, vi } from "vitest";
9+
import { resolveGoogleMeetConfig } from "./config.js";
10+
import { startCommandAgentAudioBridge } from "./realtime.js";
11+
12+
const tempDirs: string[] = [];
13+
const spawnedChildren: ChildProcess[] = [];
14+
15+
function writeBridgeCommand(): string {
16+
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-bridge-"));
17+
tempDirs.push(dir);
18+
const scriptPath = path.join(dir, "bridge-command.mjs");
19+
writeFileSync(
20+
scriptPath,
21+
[
22+
"process.on('SIGTERM', () => {",
23+
" process.exit(0);",
24+
"});",
25+
"process.stdin.resume();",
26+
"setInterval(() => {}, 1000);",
27+
"",
28+
].join("\n"),
29+
{ mode: 0o755 },
30+
);
31+
return scriptPath;
32+
}
33+
34+
function makeRecordingSpawn(): NonNullable<
35+
Parameters<typeof startCommandAgentAudioBridge>[0]["spawn"]
36+
> {
37+
return (command, args, options) => {
38+
const child = spawnChildProcess(command, args, options);
39+
spawnedChildren.push(child);
40+
return child as ReturnType<
41+
NonNullable<Parameters<typeof startCommandAgentAudioBridge>[0]["spawn"]>
42+
>;
43+
};
44+
}
45+
46+
afterEach(() => {
47+
for (const child of spawnedChildren.splice(0)) {
48+
if (child.exitCode === null && child.signalCode === null) {
49+
child.kill("SIGKILL");
50+
}
51+
}
52+
for (const dir of tempDirs.splice(0)) {
53+
rmSync(dir, { force: true, recursive: true });
54+
}
55+
vi.restoreAllMocks();
56+
});
57+
58+
describe("startCommandAgentAudioBridge real process stream errors", () => {
59+
it("contains a forced local command-pair stdout stream error through bridge stop", async () => {
60+
const bridgeScript = writeBridgeCommand();
61+
const sttSession = {
62+
connect: vi.fn(async () => {}),
63+
sendAudio: vi.fn(),
64+
close: vi.fn(),
65+
isConnected: vi.fn(() => true),
66+
};
67+
const provider: RealtimeTranscriptionProviderPlugin = {
68+
id: "openai",
69+
label: "OpenAI",
70+
defaultModel: "gpt-4o-transcribe",
71+
autoSelectOrder: 1,
72+
resolveConfig: ({ rawConfig }) => rawConfig,
73+
isConfigured: () => true,
74+
createSession: () => sttSession,
75+
};
76+
const logger = {
77+
debug: vi.fn(),
78+
info: vi.fn(),
79+
warn: vi.fn(),
80+
};
81+
82+
const handle = await startCommandAgentAudioBridge({
83+
config: resolveGoogleMeetConfig({
84+
chrome: { audioFormat: "pcm16-24khz" },
85+
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
86+
}),
87+
fullConfig: {} as never,
88+
runtime: {} as never,
89+
meetingSessionId: "meet-1",
90+
inputCommand: [process.execPath, bridgeScript, "capture"],
91+
outputCommand: [process.execPath, bridgeScript, "play"],
92+
logger: logger as never,
93+
providers: [provider],
94+
spawn: makeRecordingSpawn(),
95+
});
96+
const [outputProcess, inputProcess] = spawnedChildren;
97+
if (!inputProcess || !outputProcess) {
98+
throw new Error("Expected Google Meet bridge to spawn input and output child processes");
99+
}
100+
const inputClosed = once(inputProcess, "close");
101+
const outputClosed = once(outputProcess, "close");
102+
const originalInputKill = inputProcess.kill.bind(inputProcess);
103+
const originalOutputKill = outputProcess.kill.bind(outputProcess);
104+
const inputKillSpy = vi
105+
.spyOn(inputProcess, "kill")
106+
.mockImplementation((signal) => originalInputKill(signal));
107+
const outputKillSpy = vi
108+
.spyOn(outputProcess, "kill")
109+
.mockImplementation((signal) => originalOutputKill(signal));
110+
111+
inputProcess.stdout?.destroy(new Error("EPIPE from real bridge input stdout"));
112+
await new Promise<void>((resolve) => {
113+
setImmediate(resolve);
114+
});
115+
inputProcess.stderr?.destroy(new Error("duplicate stderr EPIPE"));
116+
117+
await Promise.all([inputClosed, outputClosed]);
118+
expect(logger.warn).toHaveBeenCalledWith(
119+
"[google-meet] audio input command stdout failed: EPIPE from real bridge input stdout",
120+
);
121+
expect(handle.getHealth().bridgeClosed).toBe(true);
122+
expect(sttSession.close).toHaveBeenCalledTimes(1);
123+
expect(inputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
124+
expect(outputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
125+
console.info(
126+
`[proof] local command-pair substitute stopped after forced input stdout stream error; inputPid=${
127+
inputProcess.pid ?? "unknown"
128+
} outputPid=${outputProcess.pid ?? "unknown"}`,
129+
);
130+
});
131+
});

0 commit comments

Comments
 (0)