Skip to content

Commit dfd2600

Browse files
committed
test(qa): centralize gateway fixture startup
1 parent a094521 commit dfd2600

6 files changed

Lines changed: 168 additions & 179 deletions

File tree

extensions/qa-lab/src/gateway-child.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,65 @@ async function expectPathMissing(filePath: string): Promise<void> {
115115
throw new Error(`expected ${filePath} to be missing`);
116116
}
117117

118+
describe("runQaGatewayCliCommand", () => {
119+
it("runs CLI commands with the Gateway fixture environment", async () => {
120+
const output = await testing.runQaGatewayCliCommand({
121+
executablePath: process.execPath,
122+
argsPrefix: [
123+
"--eval",
124+
'process.stdout.write(`${process.env.OPENCLAW_CLI}:${process.env.QA_VALUE}:${process.argv.slice(1).join(",")}`)',
125+
],
126+
args: ["voicecall", "start"],
127+
cwd: process.cwd(),
128+
env: { ...process.env, QA_VALUE: "fixture" },
129+
});
130+
131+
expect(output).toBe("1:fixture:voicecall,start");
132+
});
133+
134+
it("reports CLI stderr when a fixture command fails", async () => {
135+
await expect(
136+
testing.runQaGatewayCliCommand({
137+
executablePath: process.execPath,
138+
argsPrefix: ["--eval", 'process.stderr.write("fixture failure"); process.exit(7)'],
139+
args: [],
140+
cwd: process.cwd(),
141+
env: process.env,
142+
}),
143+
).rejects.toThrow("OpenClaw CLI exited 7: fixture failure");
144+
});
145+
});
146+
147+
describe("Gateway child fixture helpers", () => {
148+
it("creates an empty transport config seam", () => {
149+
expect(testing.createQaGatewayEmptyTransport()).toEqual({
150+
requiredPluginIds: [],
151+
createGatewayConfig: expect.any(Function),
152+
});
153+
});
154+
155+
it("resolves source and built Gateway CLI commands", async () => {
156+
const repoRoot = await tempDirs.makeTempDir("qa-gateway-command-");
157+
await mkdir(path.join(repoRoot, "src"), { recursive: true });
158+
await writeFile(path.join(repoRoot, "src", "entry.ts"), "export {};\n", "utf8");
159+
160+
expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
161+
executablePath: process.execPath,
162+
argsPrefix: ["--import", "tsx", path.join(repoRoot, "src", "entry.ts")],
163+
cwd: repoRoot,
164+
});
165+
166+
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
167+
await writeFile(path.join(repoRoot, "dist", "index.js"), "export {};\n", "utf8");
168+
expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
169+
executablePath: process.execPath,
170+
argsPrefix: [path.join(repoRoot, "dist", "index.js")],
171+
cwd: repoRoot,
172+
usePackagedPlugins: true,
173+
});
174+
});
175+
});
176+
118177
describe("buildQaRuntimeEnv", () => {
119178
it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => {
120179
const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-"));

extensions/qa-lab/src/gateway-child.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ import {
2424
resolveQaOwnerPluginIdsForProviderIds,
2525
resolveQaRuntimeHostVersion,
2626
} from "./bundled-plugin-staging.js";
27+
import {
28+
appendQaChildOutput,
29+
appendQaChildOutputTail,
30+
createQaChildOutputCapture,
31+
createQaChildOutputTail,
32+
formatQaChildOutputTail,
33+
readQaChildOutput,
34+
} from "./child-output.js";
2735
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
2836
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
2937
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
@@ -86,6 +94,66 @@ export type QaGatewayChildListeningContext = {
8694
runtimeEnv: NodeJS.ProcessEnv;
8795
};
8896

97+
function createQaGatewayEmptyTransport() {
98+
return {
99+
requiredPluginIds: [] as const,
100+
createGatewayConfig: () => ({}),
101+
} satisfies Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
102+
}
103+
104+
function resolveQaGatewayChildCommand(repoRoot: string): QaGatewayChildCommand {
105+
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
106+
const entryPath = path.join(repoRoot, relativePath);
107+
if (existsSync(entryPath)) {
108+
return {
109+
executablePath: process.execPath,
110+
argsPrefix: [entryPath],
111+
cwd: repoRoot,
112+
usePackagedPlugins: true,
113+
};
114+
}
115+
}
116+
117+
const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
118+
if (existsSync(sourceEntryPath)) {
119+
return {
120+
executablePath: process.execPath,
121+
argsPrefix: ["--import", "tsx", sourceEntryPath],
122+
cwd: repoRoot,
123+
};
124+
}
125+
126+
throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
127+
}
128+
129+
async function runQaGatewayCliCommand(params: {
130+
executablePath: string;
131+
argsPrefix: readonly string[];
132+
args: readonly string[];
133+
cwd: string;
134+
env: NodeJS.ProcessEnv;
135+
}): Promise<string> {
136+
const stdout = createQaChildOutputCapture();
137+
const stderr = createQaChildOutputTail();
138+
const child = spawn(params.executablePath, [...params.argsPrefix, ...params.args], {
139+
cwd: params.cwd,
140+
env: { ...params.env, OPENCLAW_CLI: "1" },
141+
stdio: ["ignore", "pipe", "pipe"],
142+
});
143+
child.stdout.on("data", (chunk) => appendQaChildOutput(stdout, chunk));
144+
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
145+
const exitCode = await new Promise<number>((resolve, reject) => {
146+
child.once("error", reject);
147+
child.once("close", (code) => resolve(code ?? 1));
148+
});
149+
const stdoutText = readQaChildOutput(stdout);
150+
if (exitCode !== 0) {
151+
const stderrText = formatQaChildOutputTail(stderr, "stderr");
152+
throw new Error(`OpenClaw CLI exited ${exitCode}: ${stderrText || stdoutText}`);
153+
}
154+
return stdoutText;
155+
}
156+
89157
async function getFreePort() {
90158
return await new Promise<number>((resolve, reject) => {
91159
const server = net.createServer();
@@ -378,6 +446,8 @@ export const testing = {
378446
redactQaGatewayDebugText,
379447
readQaLiveProviderConfigOverrides,
380448
resolveQaGatewayChildProviderMode,
449+
resolveQaGatewayChildCommand,
450+
createQaGatewayEmptyTransport,
381451
assertQaLiveCodexAuthAvailable,
382452
stageQaLiveApiKeyProfiles,
383453
stageQaLiveAnthropicSetupToken,
@@ -387,6 +457,7 @@ export const testing = {
387457
resolveQaOwnerPluginIdsForProviderIds,
388458
resolveQaBundledPluginSourceDir,
389459
resolveQaRuntimeHostVersion,
460+
runQaGatewayCliCommand,
390461
createQaGatewayChildLogCollector,
391462
createQaBundledPluginsDir,
392463
signalQaGatewayChildProcessTree,
@@ -671,8 +742,9 @@ export function resolveQaControlUiRoot(params: { repoRoot: string; controlUiEnab
671742
export async function startQaGatewayChild(params: {
672743
repoRoot: string;
673744
command?: QaGatewayChildCommand;
745+
useRepoCli?: boolean;
674746
providerBaseUrl?: string;
675-
transport: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
747+
transport?: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
676748
transportBaseUrl: string;
677749
controlUiAllowedOrigins?: string[];
678750
providerMode?: QaProviderMode;
@@ -694,7 +766,9 @@ export async function startQaGatewayChild(params: {
694766
);
695767
const runtimeCwd = tempRoot;
696768
const distEntryPath = path.join(params.repoRoot, "dist", "index.js");
697-
const gatewayCommand = params.command;
769+
const gatewayCommand =
770+
params.command ??
771+
(params.useRepoCli ? resolveQaGatewayChildCommand(params.repoRoot) : undefined);
698772
const gatewayExecutablePath = gatewayCommand?.executablePath;
699773
const gatewayArgsPrefix = gatewayCommand?.argsPrefix ?? [];
700774
const gatewayArgsSuffix = gatewayCommand?.argsSuffix ?? [];
@@ -707,6 +781,7 @@ export async function startQaGatewayChild(params: {
707781
const xdgCacheHome = path.join(tempRoot, "xdg-cache");
708782
const configPath = path.join(tempRoot, "openclaw.json");
709783
const gatewayToken = `qa-suite-${randomUUID()}`;
784+
const transport = params.transport ?? createQaGatewayEmptyTransport();
710785
await seedQaAgentWorkspace({
711786
workspaceDir,
712787
repoRoot: params.repoRoot,
@@ -757,8 +832,8 @@ export async function startQaGatewayChild(params: {
757832
primaryModel: params.primaryModel,
758833
alternateModel: params.alternateModel,
759834
enabledPluginIds,
760-
transportPluginIds: params.transport.requiredPluginIds,
761-
transportConfig: params.transport.createGatewayConfig({
835+
transportPluginIds: transport.requiredPluginIds,
836+
transportConfig: transport.createGatewayConfig({
762837
baseUrl: params.transportBaseUrl,
763838
}),
764839
liveProviderConfigs,
@@ -809,8 +884,11 @@ export async function startQaGatewayChild(params: {
809884

810885
try {
811886
const nodeExecPath = gatewayExecutablePath ?? (await resolveQaNodeExecPath());
887+
const cliArgsPrefix = gatewayExecutablePath
888+
? gatewayArgsPrefix
889+
: [distEntryPath, ...gatewayArgsPrefix];
812890
const buildGatewayArgs = () => [
813-
...(gatewayExecutablePath ? gatewayArgsPrefix : [distEntryPath, ...gatewayArgsPrefix]),
891+
...cliArgsPrefix,
814892
"gateway",
815893
"run",
816894
"--port",
@@ -1104,6 +1182,15 @@ export async function startQaGatewayChild(params: {
11041182
configPath,
11051183
runtimeEnv: runningEnv,
11061184
logs,
1185+
runCli(args: readonly string[]) {
1186+
return runQaGatewayCliCommand({
1187+
executablePath: nodeExecPath,
1188+
argsPrefix: cliArgsPrefix,
1189+
args,
1190+
cwd: gatewayCwd,
1191+
env: runningEnv,
1192+
});
1193+
},
11071194
signalProcess(signal: NodeJS.Signals) {
11081195
if (!activeChild.pid) {
11091196
throw new Error("qa gateway child has no pid");

test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,6 @@ async function writeEntry(root: string, relativePath: string) {
1818
}
1919

2020
describe("gateway MCP real transport producer", () => {
21-
it("uses the source CLI entry when build output is absent", async () => {
22-
const root = createRepoRoot();
23-
const entryPath = await writeEntry(root, "src/entry.ts");
24-
25-
const cli = testing.resolveOpenClawCliInvocation(root);
26-
27-
expect(cli.command).toBe(process.execPath);
28-
expect(cli.argsPrefix).toStrictEqual(["--import", "tsx", entryPath]);
29-
expect(cli.cwd).toBe(root);
30-
expect(cli.gatewayCommand).toMatchObject({
31-
executablePath: process.execPath,
32-
argsPrefix: ["--import", "tsx", entryPath],
33-
cwd: root,
34-
});
35-
});
36-
37-
it("prefers built package output when it exists", async () => {
38-
const root = createRepoRoot();
39-
const distEntry = await writeEntry(root, "dist/index.mjs");
40-
await writeEntry(root, "src/entry.ts");
41-
42-
const cli = testing.resolveOpenClawCliInvocation(root);
43-
44-
expect(cli.argsPrefix).toStrictEqual([distEntry]);
45-
expect(cli.gatewayCommand).toMatchObject({
46-
argsPrefix: [distEntry],
47-
usePackagedPlugins: true,
48-
});
49-
});
50-
5121
it("uses the source channel MCP module when build output is absent", async () => {
5222
const root = createRepoRoot();
5323
const channelServerPath = await writeEntry(root, "src/mcp/channel-server.ts");

test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { WebSocket, WebSocketServer, type RawData } from "ws";
1111
import {
1212
QA_EVIDENCE_FILENAME,
1313
startQaGatewayChild,
14-
type QaGatewayChildCommand,
1514
type QaEvidenceSummaryJson,
1615
type QaGatewayChildListeningContext,
1716
} from "../../../../extensions/qa-lab/api.js";
@@ -58,13 +57,6 @@ type GatewayProxy = {
5857
url: string;
5958
};
6059

61-
type OpenClawCliInvocation = {
62-
argsPrefix: string[];
63-
command: string;
64-
cwd: string;
65-
gatewayCommand: QaGatewayChildCommand;
66-
};
67-
6860
type ChannelMcpInvocation = {
6961
args: string[];
7062
command: string;
@@ -220,50 +212,6 @@ function withFixturePlugin(config: OpenClawConfig, pluginDir: string): OpenClawC
220212
};
221213
}
222214

223-
function emptyTransport() {
224-
return {
225-
requiredPluginIds: [] as string[],
226-
createGatewayConfig: () => ({}),
227-
};
228-
}
229-
230-
function resolveOpenClawCliInvocation(repoRoot: string): OpenClawCliInvocation {
231-
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
232-
const entryPath = path.join(repoRoot, relativePath);
233-
if (existsSync(entryPath)) {
234-
const argsPrefix = [entryPath];
235-
return {
236-
argsPrefix,
237-
command: process.execPath,
238-
cwd: repoRoot,
239-
gatewayCommand: {
240-
executablePath: process.execPath,
241-
argsPrefix,
242-
cwd: repoRoot,
243-
usePackagedPlugins: true,
244-
},
245-
};
246-
}
247-
}
248-
249-
const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
250-
if (existsSync(sourceEntryPath)) {
251-
const argsPrefix = ["--import", "tsx", sourceEntryPath];
252-
return {
253-
argsPrefix,
254-
command: process.execPath,
255-
cwd: repoRoot,
256-
gatewayCommand: {
257-
executablePath: process.execPath,
258-
argsPrefix,
259-
cwd: repoRoot,
260-
},
261-
};
262-
}
263-
264-
throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
265-
}
266-
267215
function resolveChannelMcpInvocation(params: {
268216
gatewayToken: string;
269217
gatewayUrl: string;
@@ -525,8 +473,7 @@ async function approvePendingMcpPairing(gateway: Awaited<ReturnType<typeof start
525473
async function runGatewaySmokeProof(options: ProducerOptions): Promise<string> {
526474
const gateway = await startQaGatewayChild({
527475
repoRoot: options.repoRoot,
528-
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
529-
transport: emptyTransport(),
476+
useRepoCli: true,
530477
transportBaseUrl: "http://127.0.0.1",
531478
controlUiEnabled: false,
532479
});
@@ -585,8 +532,7 @@ async function runMcpGatewayStartupRetryProof(options: ProducerOptions): Promise
585532
};
586533
gateway = await startQaGatewayChild({
587534
repoRoot: options.repoRoot,
588-
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
589-
transport: emptyTransport(),
535+
useRepoCli: true,
590536
transportBaseUrl: "http://127.0.0.1",
591537
controlUiEnabled: false,
592538
onListening,
@@ -813,5 +759,4 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
813759

814760
export const testing = {
815761
resolveChannelMcpInvocation,
816-
resolveOpenClawCliInvocation,
817762
};

0 commit comments

Comments
 (0)