Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
test(qa): centralize gateway fixture startup
  • Loading branch information
RomneyDa committed Jul 6, 2026
commit dfd2600d04ed88c2f7224f09d1e100d7f52658eb
59 changes: 59 additions & 0 deletions extensions/qa-lab/src/gateway-child.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,65 @@ async function expectPathMissing(filePath: string): Promise<void> {
throw new Error(`expected ${filePath} to be missing`);
}

describe("runQaGatewayCliCommand", () => {
it("runs CLI commands with the Gateway fixture environment", async () => {
const output = await testing.runQaGatewayCliCommand({
executablePath: process.execPath,
argsPrefix: [
"--eval",
'process.stdout.write(`${process.env.OPENCLAW_CLI}:${process.env.QA_VALUE}:${process.argv.slice(1).join(",")}`)',
],
args: ["voicecall", "start"],
cwd: process.cwd(),
env: { ...process.env, QA_VALUE: "fixture" },
});

expect(output).toBe("1:fixture:voicecall,start");
});

it("reports CLI stderr when a fixture command fails", async () => {
await expect(
testing.runQaGatewayCliCommand({
executablePath: process.execPath,
argsPrefix: ["--eval", 'process.stderr.write("fixture failure"); process.exit(7)'],
args: [],
cwd: process.cwd(),
env: process.env,
}),
).rejects.toThrow("OpenClaw CLI exited 7: fixture failure");
});
});

describe("Gateway child fixture helpers", () => {
it("creates an empty transport config seam", () => {
expect(testing.createQaGatewayEmptyTransport()).toEqual({
requiredPluginIds: [],
createGatewayConfig: expect.any(Function),
});
});

it("resolves source and built Gateway CLI commands", async () => {
const repoRoot = await tempDirs.makeTempDir("qa-gateway-command-");
await mkdir(path.join(repoRoot, "src"), { recursive: true });
await writeFile(path.join(repoRoot, "src", "entry.ts"), "export {};\n", "utf8");

expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
executablePath: process.execPath,
argsPrefix: ["--import", "tsx", path.join(repoRoot, "src", "entry.ts")],
cwd: repoRoot,
});

await mkdir(path.join(repoRoot, "dist"), { recursive: true });
await writeFile(path.join(repoRoot, "dist", "index.js"), "export {};\n", "utf8");
expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
executablePath: process.execPath,
argsPrefix: [path.join(repoRoot, "dist", "index.js")],
cwd: repoRoot,
usePackagedPlugins: true,
});
});
});

describe("buildQaRuntimeEnv", () => {
it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => {
const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-"));
Expand Down
97 changes: 92 additions & 5 deletions extensions/qa-lab/src/gateway-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ import {
resolveQaOwnerPluginIdsForProviderIds,
resolveQaRuntimeHostVersion,
} from "./bundled-plugin-staging.js";
import {
appendQaChildOutput,
appendQaChildOutputTail,
createQaChildOutputCapture,
createQaChildOutputTail,
formatQaChildOutputTail,
readQaChildOutput,
} from "./child-output.js";
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
Expand Down Expand Up @@ -86,6 +94,66 @@ export type QaGatewayChildListeningContext = {
runtimeEnv: NodeJS.ProcessEnv;
};

function createQaGatewayEmptyTransport() {
return {
requiredPluginIds: [] as const,
createGatewayConfig: () => ({}),
} satisfies Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
}

function resolveQaGatewayChildCommand(repoRoot: string): QaGatewayChildCommand {
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
const entryPath = path.join(repoRoot, relativePath);
if (existsSync(entryPath)) {
return {
executablePath: process.execPath,
argsPrefix: [entryPath],
cwd: repoRoot,
usePackagedPlugins: true,
};
}
}

const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
if (existsSync(sourceEntryPath)) {
return {
executablePath: process.execPath,
argsPrefix: ["--import", "tsx", sourceEntryPath],
cwd: repoRoot,
};
}

throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
}

async function runQaGatewayCliCommand(params: {
executablePath: string;
argsPrefix: readonly string[];
args: readonly string[];
cwd: string;
env: NodeJS.ProcessEnv;
}): Promise<string> {
const stdout = createQaChildOutputCapture();
const stderr = createQaChildOutputTail();
const child = spawn(params.executablePath, [...params.argsPrefix, ...params.args], {
cwd: params.cwd,
env: { ...params.env, OPENCLAW_CLI: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => appendQaChildOutput(stdout, chunk));
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
const exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
const stdoutText = readQaChildOutput(stdout);
if (exitCode !== 0) {
const stderrText = formatQaChildOutputTail(stderr, "stderr");
throw new Error(`OpenClaw CLI exited ${exitCode}: ${stderrText || stdoutText}`);
}
return stdoutText;
}

async function getFreePort() {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
Expand Down Expand Up @@ -378,6 +446,8 @@ export const testing = {
redactQaGatewayDebugText,
readQaLiveProviderConfigOverrides,
resolveQaGatewayChildProviderMode,
resolveQaGatewayChildCommand,
createQaGatewayEmptyTransport,
assertQaLiveCodexAuthAvailable,
stageQaLiveApiKeyProfiles,
stageQaLiveAnthropicSetupToken,
Expand All @@ -387,6 +457,7 @@ export const testing = {
resolveQaOwnerPluginIdsForProviderIds,
resolveQaBundledPluginSourceDir,
resolveQaRuntimeHostVersion,
runQaGatewayCliCommand,
createQaGatewayChildLogCollector,
createQaBundledPluginsDir,
signalQaGatewayChildProcessTree,
Expand Down Expand Up @@ -671,8 +742,9 @@ export function resolveQaControlUiRoot(params: { repoRoot: string; controlUiEnab
export async function startQaGatewayChild(params: {
repoRoot: string;
command?: QaGatewayChildCommand;
useRepoCli?: boolean;
providerBaseUrl?: string;
transport: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
transport?: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
transportBaseUrl: string;
controlUiAllowedOrigins?: string[];
providerMode?: QaProviderMode;
Expand All @@ -694,7 +766,9 @@ export async function startQaGatewayChild(params: {
);
const runtimeCwd = tempRoot;
const distEntryPath = path.join(params.repoRoot, "dist", "index.js");
const gatewayCommand = params.command;
const gatewayCommand =
params.command ??
(params.useRepoCli ? resolveQaGatewayChildCommand(params.repoRoot) : undefined);
const gatewayExecutablePath = gatewayCommand?.executablePath;
const gatewayArgsPrefix = gatewayCommand?.argsPrefix ?? [];
const gatewayArgsSuffix = gatewayCommand?.argsSuffix ?? [];
Expand All @@ -707,6 +781,7 @@ export async function startQaGatewayChild(params: {
const xdgCacheHome = path.join(tempRoot, "xdg-cache");
const configPath = path.join(tempRoot, "openclaw.json");
const gatewayToken = `qa-suite-${randomUUID()}`;
const transport = params.transport ?? createQaGatewayEmptyTransport();
await seedQaAgentWorkspace({
workspaceDir,
repoRoot: params.repoRoot,
Expand Down Expand Up @@ -757,8 +832,8 @@ export async function startQaGatewayChild(params: {
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
enabledPluginIds,
transportPluginIds: params.transport.requiredPluginIds,
transportConfig: params.transport.createGatewayConfig({
transportPluginIds: transport.requiredPluginIds,
transportConfig: transport.createGatewayConfig({
baseUrl: params.transportBaseUrl,
}),
liveProviderConfigs,
Expand Down Expand Up @@ -809,8 +884,11 @@ export async function startQaGatewayChild(params: {

try {
const nodeExecPath = gatewayExecutablePath ?? (await resolveQaNodeExecPath());
const cliArgsPrefix = gatewayExecutablePath
? gatewayArgsPrefix
: [distEntryPath, ...gatewayArgsPrefix];
const buildGatewayArgs = () => [
...(gatewayExecutablePath ? gatewayArgsPrefix : [distEntryPath, ...gatewayArgsPrefix]),
...cliArgsPrefix,
"gateway",
"run",
"--port",
Expand Down Expand Up @@ -1104,6 +1182,15 @@ export async function startQaGatewayChild(params: {
configPath,
runtimeEnv: runningEnv,
logs,
runCli(args: readonly string[]) {
return runQaGatewayCliCommand({
executablePath: nodeExecPath,
argsPrefix: cliArgsPrefix,
args,
cwd: gatewayCwd,
env: runningEnv,
});
},
signalProcess(signal: NodeJS.Signals) {
if (!activeChild.pid) {
throw new Error("qa gateway child has no pid");
Expand Down
30 changes: 0 additions & 30 deletions test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,6 @@ async function writeEntry(root: string, relativePath: string) {
}

describe("gateway MCP real transport producer", () => {
it("uses the source CLI entry when build output is absent", async () => {
const root = createRepoRoot();
const entryPath = await writeEntry(root, "src/entry.ts");

const cli = testing.resolveOpenClawCliInvocation(root);

expect(cli.command).toBe(process.execPath);
expect(cli.argsPrefix).toStrictEqual(["--import", "tsx", entryPath]);
expect(cli.cwd).toBe(root);
expect(cli.gatewayCommand).toMatchObject({
executablePath: process.execPath,
argsPrefix: ["--import", "tsx", entryPath],
cwd: root,
});
});

it("prefers built package output when it exists", async () => {
const root = createRepoRoot();
const distEntry = await writeEntry(root, "dist/index.mjs");
await writeEntry(root, "src/entry.ts");

const cli = testing.resolveOpenClawCliInvocation(root);

expect(cli.argsPrefix).toStrictEqual([distEntry]);
expect(cli.gatewayCommand).toMatchObject({
argsPrefix: [distEntry],
usePackagedPlugins: true,
});
});

it("uses the source channel MCP module when build output is absent", async () => {
const root = createRepoRoot();
const channelServerPath = await writeEntry(root, "src/mcp/channel-server.ts");
Expand Down
59 changes: 2 additions & 57 deletions test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { WebSocket, WebSocketServer, type RawData } from "ws";
import {
QA_EVIDENCE_FILENAME,
startQaGatewayChild,
type QaGatewayChildCommand,
type QaEvidenceSummaryJson,
type QaGatewayChildListeningContext,
} from "../../../../extensions/qa-lab/api.js";
Expand Down Expand Up @@ -58,13 +57,6 @@ type GatewayProxy = {
url: string;
};

type OpenClawCliInvocation = {
argsPrefix: string[];
command: string;
cwd: string;
gatewayCommand: QaGatewayChildCommand;
};

type ChannelMcpInvocation = {
args: string[];
command: string;
Expand Down Expand Up @@ -220,50 +212,6 @@ function withFixturePlugin(config: OpenClawConfig, pluginDir: string): OpenClawC
};
}

function emptyTransport() {
return {
requiredPluginIds: [] as string[],
createGatewayConfig: () => ({}),
};
}

function resolveOpenClawCliInvocation(repoRoot: string): OpenClawCliInvocation {
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
const entryPath = path.join(repoRoot, relativePath);
if (existsSync(entryPath)) {
const argsPrefix = [entryPath];
return {
argsPrefix,
command: process.execPath,
cwd: repoRoot,
gatewayCommand: {
executablePath: process.execPath,
argsPrefix,
cwd: repoRoot,
usePackagedPlugins: true,
},
};
}
}

const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
if (existsSync(sourceEntryPath)) {
const argsPrefix = ["--import", "tsx", sourceEntryPath];
return {
argsPrefix,
command: process.execPath,
cwd: repoRoot,
gatewayCommand: {
executablePath: process.execPath,
argsPrefix,
cwd: repoRoot,
},
};
}

throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
}

function resolveChannelMcpInvocation(params: {
gatewayToken: string;
gatewayUrl: string;
Expand Down Expand Up @@ -525,8 +473,7 @@ async function approvePendingMcpPairing(gateway: Awaited<ReturnType<typeof start
async function runGatewaySmokeProof(options: ProducerOptions): Promise<string> {
const gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
transport: emptyTransport(),
useRepoCli: true,
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
});
Expand Down Expand Up @@ -585,8 +532,7 @@ async function runMcpGatewayStartupRetryProof(options: ProducerOptions): Promise
};
gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
transport: emptyTransport(),
useRepoCli: true,
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
onListening,
Expand Down Expand Up @@ -813,5 +759,4 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

export const testing = {
resolveChannelMcpInvocation,
resolveOpenClawCliInvocation,
};
Loading