mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 19:23:39 +00:00
refactor(qa): simplify transport adapter contracts (#99632)
* feat(qa): add transport capability contracts * fix(qa): scope sequence support by channel * refactor(qa): simplify transport adapter contracts * fix(qa): clarify unsupported transport method errors
This commit is contained in:
@@ -71,7 +71,8 @@ describe("crabline transport", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await transport.sendNativeCommand({
|
||||
expect(transport.sendNativeCommand).toBeTypeOf("function");
|
||||
await transport.sendNativeCommand?.({
|
||||
command: "stop",
|
||||
conversation: { id: "alice", kind: "direct" },
|
||||
senderId: "alice",
|
||||
@@ -218,8 +219,9 @@ describe("crabline transport", () => {
|
||||
text: "final marker",
|
||||
});
|
||||
|
||||
expect(transport.waitForOutboundSequence).toBeTypeOf("function");
|
||||
await expect(
|
||||
transport.waitForOutboundSequence({
|
||||
transport.waitForOutboundSequence!({
|
||||
conversationId: "-1001234567890",
|
||||
finalSettleMs: 0,
|
||||
finalTextIncludes: "final marker",
|
||||
@@ -248,6 +250,8 @@ describe("crabline transport", () => {
|
||||
try {
|
||||
expect(transport.id).toBe("crabline");
|
||||
expect(transport.requiredPluginIds).toEqual(["slack"]);
|
||||
expect(transport.sendNativeCommand).toBeUndefined();
|
||||
expect(transport.waitForOutboundSequence).toBeUndefined();
|
||||
expect(transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" })).toMatchObject({
|
||||
channels: {
|
||||
slack: {
|
||||
|
||||
@@ -338,6 +338,11 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
|
||||
readonly #adapter: StartedOpenClawCrablineAdapter;
|
||||
readonly #selection: OpenClawCrablineChannelDriverSelection;
|
||||
readonly #state: QaCrablineTransportState;
|
||||
readonly sendNativeCommand?: (input: QaTransportNativeCommandInput) => Promise<void>;
|
||||
readonly waitForOutboundSequence?: (input: QaTransportOutboundSequenceMatch) => Promise<{
|
||||
events: QaTransportOutboundEvent[];
|
||||
final: QaBusMessage;
|
||||
}>;
|
||||
|
||||
constructor(params: {
|
||||
adapter: StartedOpenClawCrablineAdapter;
|
||||
@@ -354,6 +359,21 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
|
||||
this.#adapter = params.adapter;
|
||||
this.#selection = params.selection;
|
||||
this.#state = params.state;
|
||||
if (params.selection.channel === "telegram") {
|
||||
this.sendNativeCommand = async (input) => {
|
||||
const { command, ...message } = input;
|
||||
await this.sendInbound({
|
||||
...message,
|
||||
text: `/${command}`,
|
||||
nativeCommand: { name: command },
|
||||
});
|
||||
};
|
||||
this.waitForOutboundSequence = async (input) =>
|
||||
await waitForQaTransportOutboundSequence({
|
||||
input,
|
||||
readEvents: () => this.#state.getOutboundEvents(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createGatewayConfig = (params: { baseUrl: string }): QaTransportGatewayConfig =>
|
||||
@@ -378,27 +398,6 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
|
||||
|
||||
createRuntimeEnvPatch = () => this.#adapter.createChannelDriverSmokeEnv({});
|
||||
|
||||
override async sendNativeCommand(input: QaTransportNativeCommandInput): Promise<void> {
|
||||
if (this.#selection.channel !== "telegram") {
|
||||
throw new Error(
|
||||
`Crabline ${this.#selection.channel} does not support native command injection.`,
|
||||
);
|
||||
}
|
||||
const { command, ...message } = input;
|
||||
await this.sendInbound({
|
||||
...message,
|
||||
text: `/${command}`,
|
||||
nativeCommand: { name: command },
|
||||
});
|
||||
}
|
||||
|
||||
override async waitForOutboundSequence(input: QaTransportOutboundSequenceMatch) {
|
||||
return await waitForQaTransportOutboundSequence({
|
||||
input,
|
||||
readEvents: () => this.#state.getOutboundEvents(),
|
||||
});
|
||||
}
|
||||
|
||||
handleAction = async (_params: {
|
||||
action: QaTransportActionName;
|
||||
args: Record<string, unknown>;
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function runQaManualLane(params: QaManualLaneParams) {
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let lab: Awaited<ReturnType<typeof startQaLabServer>> | undefined;
|
||||
let mock: Awaited<ReturnType<typeof startQaProviderServer>> | undefined;
|
||||
let transportCleanup: (() => Promise<void>) | undefined;
|
||||
let result: ManualLaneResult | undefined;
|
||||
let cleanupError: Error | undefined;
|
||||
let runError: unknown;
|
||||
@@ -86,10 +87,14 @@ export async function runQaManualLane(params: QaManualLaneParams) {
|
||||
repoRoot: params.repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
const transport = createQaTransportAdapter({
|
||||
id: params.transportId ?? "qa-channel",
|
||||
const transportFactoryResult = await createQaTransportAdapter({
|
||||
channelId: params.transportId ?? "qa-channel",
|
||||
driver: params.transportId ?? "qa-channel",
|
||||
outputDir: params.repoRoot,
|
||||
state: lab.state,
|
||||
});
|
||||
const transport = transportFactoryResult.adapter;
|
||||
transportCleanup = transportFactoryResult.cleanup;
|
||||
mock = await startQaProviderServer(params.providerMode);
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: params.repoRoot,
|
||||
@@ -164,7 +169,12 @@ export async function runQaManualLane(params: QaManualLaneParams) {
|
||||
} catch (error) {
|
||||
runError = error;
|
||||
} finally {
|
||||
cleanupError = await stopManualLaneResources({ gateway, lab, mock });
|
||||
let transportCleanupError: Error | undefined;
|
||||
await transportCleanup?.().catch((error: unknown) => {
|
||||
transportCleanupError = normalizeManualLaneCleanupError(error);
|
||||
});
|
||||
const resourceCleanupError = await stopManualLaneResources({ gateway, lab, mock });
|
||||
cleanupError = transportCleanupError ?? resourceCleanupError;
|
||||
}
|
||||
if (runError) {
|
||||
throw new Error(formatErrorMessage(runError), { cause: runError });
|
||||
|
||||
@@ -98,18 +98,18 @@ describe("qa channel transport", () => {
|
||||
).rejects.toThrow("last probe error: channels.status exploded");
|
||||
});
|
||||
|
||||
it("inherits the shared normalized message capabilities", async () => {
|
||||
it("uses the shared normalized message state", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
|
||||
const inbound = await transport.capabilities.sendInboundMessage({
|
||||
const inbound = await transport.sendInbound({
|
||||
accountId: "default",
|
||||
conversation: { id: "dm:qa-operator", kind: "direct" },
|
||||
senderId: "qa-operator",
|
||||
text: "hello from the operator",
|
||||
});
|
||||
|
||||
expect(transport.capabilities.getNormalizedMessageState().messages).toHaveLength(1);
|
||||
const message = await transport.capabilities.readNormalizedMessage({
|
||||
expect(transport.state.getSnapshot().messages).toHaveLength(1);
|
||||
const message = await transport.state.readMessage({
|
||||
messageId: inbound.id,
|
||||
});
|
||||
if (!message) {
|
||||
@@ -161,11 +161,11 @@ describe("qa channel transport", () => {
|
||||
let injected = false;
|
||||
|
||||
await expect(
|
||||
transport.capabilities.waitForCondition(
|
||||
transport.waitForCondition(
|
||||
async () => {
|
||||
if (!injected) {
|
||||
injected = true;
|
||||
await transport.capabilities.injectOutboundMessage({
|
||||
await transport.state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:qa-operator",
|
||||
text: "⚠️ agent failed before reply: synthetic failure for wait helper",
|
||||
@@ -182,22 +182,20 @@ describe("qa channel transport", () => {
|
||||
it("captures a fresh failure cursor for each wait helper call", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
|
||||
await transport.capabilities.injectOutboundMessage({
|
||||
await transport.state.addOutboundMessage({
|
||||
accountId: "default",
|
||||
to: "dm:qa-operator",
|
||||
text: "⚠️ agent failed before reply: stale failure should not leak",
|
||||
});
|
||||
|
||||
await expect(transport.capabilities.waitForCondition(async () => "ok", 50, 10)).resolves.toBe(
|
||||
"ok",
|
||||
);
|
||||
await expect(transport.waitForCondition(async () => "ok", 50, 10)).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("keeps oversized wait helper intervals within the timeout", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
|
||||
await expect(
|
||||
transport.capabilities.waitForCondition(async () => undefined, 5, Number.MAX_SAFE_INTEGER),
|
||||
transport.waitForCondition(async () => undefined, 5, Number.MAX_SAFE_INTEGER),
|
||||
).rejects.toThrow("timed out after 5ms");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,16 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { QaBusState } from "./bus-state.js";
|
||||
import { QaSuiteInfraError } from "./errors.js";
|
||||
import { getQaProvider } from "./providers/index.js";
|
||||
import { QaStateBackedTransportAdapter } from "./qa-transport.js";
|
||||
import {
|
||||
QaStateBackedTransportAdapter,
|
||||
waitForQaTransportOutboundSequence,
|
||||
} from "./qa-transport.js";
|
||||
import type {
|
||||
QaTransportActionName,
|
||||
QaTransportGatewayConfig,
|
||||
QaTransportGatewayClient,
|
||||
QaTransportNativeCommandInput,
|
||||
QaTransportOutboundSequenceMatch,
|
||||
QaTransportReportParams,
|
||||
} from "./qa-transport.js";
|
||||
import { qaChannelPlugin } from "./runtime-api.js";
|
||||
@@ -148,7 +152,7 @@ class QaChannelTransport extends QaStateBackedTransportAdapter {
|
||||
replyChannel: QA_CHANNEL_ID,
|
||||
replyTo: target,
|
||||
});
|
||||
override async sendNativeCommand(input: QaTransportNativeCommandInput): Promise<void> {
|
||||
async sendNativeCommand(input: QaTransportNativeCommandInput): Promise<void> {
|
||||
const { command, ...message } = input;
|
||||
await this.sendInbound({
|
||||
...message,
|
||||
@@ -156,6 +160,12 @@ class QaChannelTransport extends QaStateBackedTransportAdapter {
|
||||
nativeCommand: { name: command },
|
||||
});
|
||||
}
|
||||
async waitForOutboundSequence(input: QaTransportOutboundSequenceMatch) {
|
||||
return await waitForQaTransportOutboundSequence({
|
||||
input,
|
||||
readEvents: () => this.state.getSnapshot().events,
|
||||
});
|
||||
}
|
||||
handleAction = handleQaChannelAction;
|
||||
createReportNotes = createQaChannelReportNotes;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
// Qa Lab tests cover qa transport registry plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeQaTransportId } from "./qa-transport-registry.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import { createQaChannelTransport } from "./qa-channel-transport.js";
|
||||
import {
|
||||
createQaTransportAdapter,
|
||||
createQaTransportAdapterFactoryRegistry,
|
||||
normalizeQaTransportId,
|
||||
type QaTransportAdapterFactory,
|
||||
type QaTransportFactoryContext,
|
||||
} from "./qa-transport-registry.js";
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
|
||||
function createFactoryContext(
|
||||
overrides: Partial<QaTransportFactoryContext> = {},
|
||||
): QaTransportFactoryContext {
|
||||
return {
|
||||
channelId: "qa-channel",
|
||||
driver: "qa-channel",
|
||||
outputDir: ".artifacts/qa-e2e/transport-contract-test",
|
||||
state: createQaBusState(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("qa transport registry", () => {
|
||||
it("rejects inherited prototype keys as unsupported transport ids", () => {
|
||||
@@ -9,4 +30,68 @@ describe("qa transport registry", () => {
|
||||
"unsupported QA transport: __proto__",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates QA Channel through the default async registry", async () => {
|
||||
const created = await createQaTransportAdapter(createFactoryContext());
|
||||
|
||||
expect(created.adapter.id).toBe("qa-channel");
|
||||
await created.cleanup();
|
||||
});
|
||||
|
||||
it("selects an injected matching factory", async () => {
|
||||
const adapter = createQaChannelTransport(createQaBusState());
|
||||
const skippedCreate = vi.fn(async () => adapter);
|
||||
const selectedCreate = vi.fn(async () => adapter);
|
||||
const factories: QaTransportAdapterFactory[] = [
|
||||
{ id: "skipped", matches: () => false, create: skippedCreate },
|
||||
{ id: "selected", matches: () => true, create: selectedCreate },
|
||||
];
|
||||
const registry = createQaTransportAdapterFactoryRegistry(factories);
|
||||
|
||||
const created = await registry.create(createFactoryContext());
|
||||
|
||||
expect(created.adapter).toBe(adapter);
|
||||
expect(skippedCreate).not.toHaveBeenCalled();
|
||||
expect(selectedCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns cleanup owned by the selected adapter", async () => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const adapter: QaTransportAdapter = createQaChannelTransport(createQaBusState());
|
||||
adapter.cleanup = cleanup;
|
||||
const factory: QaTransportAdapterFactory = {
|
||||
id: "cleanup",
|
||||
matches: () => true,
|
||||
async create() {
|
||||
return adapter;
|
||||
},
|
||||
};
|
||||
const registry = createQaTransportAdapterFactoryRegistry([factory]);
|
||||
const created = await registry.create(createFactoryContext());
|
||||
|
||||
await created.cleanup();
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports no-match and startup failures with transport context", async () => {
|
||||
const context = createFactoryContext();
|
||||
const emptyRegistry = createQaTransportAdapterFactoryRegistry([]);
|
||||
await expect(emptyRegistry.create(context)).rejects.toThrow(
|
||||
"no QA transport factory for qa-channel:qa-channel",
|
||||
);
|
||||
|
||||
const brokenRegistry = createQaTransportAdapterFactoryRegistry([
|
||||
{
|
||||
id: "broken",
|
||||
matches: () => true,
|
||||
async create() {
|
||||
throw new Error("provider boot failed");
|
||||
},
|
||||
},
|
||||
]);
|
||||
await expect(brokenRegistry.create(context)).rejects.toThrow(
|
||||
"broken failed to create QA transport qa-channel:qa-channel: provider boot failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,37 +7,113 @@ import {
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
|
||||
export type QaTransportId = "qa-channel";
|
||||
export type QaTransportDriver = QaTransportId | "crabline";
|
||||
|
||||
export type QaTransportFactoryContext = {
|
||||
channelId: string;
|
||||
driver: QaTransportDriver;
|
||||
outputDir: string;
|
||||
state: QaBusState;
|
||||
};
|
||||
|
||||
export type QaTransportAdapterFactoryResult = {
|
||||
adapter: QaTransportAdapter;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type QaTransportAdapterFactory = {
|
||||
id: string;
|
||||
matches: (context: Pick<QaTransportFactoryContext, "channelId" | "driver">) => boolean;
|
||||
create: (context: QaTransportFactoryContext) => Promise<QaTransportAdapter>;
|
||||
};
|
||||
|
||||
export type QaTransportAdapterFactoryRegistry = {
|
||||
create: (context: QaTransportFactoryContext) => Promise<QaTransportAdapterFactoryResult>;
|
||||
};
|
||||
|
||||
const DEFAULT_QA_TRANSPORT_ID: QaTransportId = "qa-channel";
|
||||
|
||||
const QA_TRANSPORT_REGISTRY = {
|
||||
"qa-channel": {
|
||||
create: createQaChannelTransport,
|
||||
defaultSuiteConcurrency: QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY,
|
||||
const QA_CHANNEL_TRANSPORT_FACTORY: QaTransportAdapterFactory = {
|
||||
id: "qa-channel",
|
||||
matches: ({ channelId, driver }) => driver === "qa-channel" && channelId === "qa-channel",
|
||||
async create(context) {
|
||||
return createQaChannelTransport(context.state);
|
||||
},
|
||||
} as const satisfies Record<
|
||||
QaTransportId,
|
||||
{
|
||||
create: (state: QaBusState) => QaTransportAdapter;
|
||||
defaultSuiteConcurrency: number;
|
||||
};
|
||||
|
||||
const CRABLINE_TRANSPORT_FACTORY: QaTransportAdapterFactory = {
|
||||
id: "crabline",
|
||||
matches: ({ driver }) => driver === "crabline",
|
||||
async create(context) {
|
||||
const { resolveOpenClawCrablineChannelDriverSelection } = await import("@openclaw/crabline");
|
||||
const selection = resolveOpenClawCrablineChannelDriverSelection({ channel: context.channelId });
|
||||
const { createQaCrablineTransportAdapter } = await import("./crabline-transport.js");
|
||||
return await createQaCrablineTransportAdapter({
|
||||
outputDir: context.outputDir,
|
||||
selection,
|
||||
state: context.state,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_QA_TRANSPORT_FACTORIES = [
|
||||
QA_CHANNEL_TRANSPORT_FACTORY,
|
||||
CRABLINE_TRANSPORT_FACTORY,
|
||||
] as const;
|
||||
|
||||
function requireQaTransportFactory(
|
||||
factories: readonly QaTransportAdapterFactory[],
|
||||
context: Pick<QaTransportFactoryContext, "channelId" | "driver">,
|
||||
) {
|
||||
const factory = factories.find((candidate) => candidate.matches(context));
|
||||
if (!factory) {
|
||||
throw new Error(`no QA transport factory for ${context.driver}:${context.channelId}`);
|
||||
}
|
||||
>;
|
||||
return factory;
|
||||
}
|
||||
|
||||
export function createQaTransportAdapterFactoryRegistry(
|
||||
factories: readonly QaTransportAdapterFactory[] = DEFAULT_QA_TRANSPORT_FACTORIES,
|
||||
): QaTransportAdapterFactoryRegistry {
|
||||
return {
|
||||
async create(context) {
|
||||
const factory = requireQaTransportFactory(factories, context);
|
||||
let adapter: QaTransportAdapter;
|
||||
try {
|
||||
adapter = await factory.create(context);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`${factory.id} failed to create QA transport ${context.driver}:${context.channelId}: ${message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return {
|
||||
adapter,
|
||||
cleanup: async () => {
|
||||
await adapter.cleanup?.();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const qaTransportAdapterFactoryRegistry = createQaTransportAdapterFactoryRegistry();
|
||||
|
||||
export function normalizeQaTransportId(input?: string | null): QaTransportId {
|
||||
const transportId = input?.trim() || DEFAULT_QA_TRANSPORT_ID;
|
||||
if (Object.hasOwn(QA_TRANSPORT_REGISTRY, transportId)) {
|
||||
return transportId as QaTransportId;
|
||||
if (transportId === "qa-channel") {
|
||||
return transportId;
|
||||
}
|
||||
throw new Error(`unsupported QA transport: ${transportId}`);
|
||||
}
|
||||
|
||||
export function createQaTransportAdapter(params: {
|
||||
id: QaTransportId;
|
||||
state: QaBusState;
|
||||
}): QaTransportAdapter {
|
||||
return QA_TRANSPORT_REGISTRY[params.id].create(params.state);
|
||||
export async function createQaTransportAdapter(
|
||||
context: QaTransportFactoryContext,
|
||||
): Promise<QaTransportAdapterFactoryResult> {
|
||||
return await qaTransportAdapterFactoryRegistry.create(context);
|
||||
}
|
||||
|
||||
export function defaultQaSuiteConcurrencyForTransport(id: QaTransportId): number {
|
||||
return QA_TRANSPORT_REGISTRY[id].defaultSuiteConcurrency;
|
||||
return id === "qa-channel" ? QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY : 1;
|
||||
}
|
||||
|
||||
@@ -99,32 +99,6 @@ export type QaTransportNativeCommandInput = Omit<
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type QaTransportCapabilities = {
|
||||
sendInboundMessage: QaTransportState["addInboundMessage"];
|
||||
injectOutboundMessage: QaTransportState["addOutboundMessage"];
|
||||
waitForOutboundMessage: (input: QaBusWaitForInput) => Promise<unknown>;
|
||||
getNormalizedMessageState: () => QaBusStateSnapshot;
|
||||
resetNormalizedMessageState: () => Promise<void>;
|
||||
readNormalizedMessage: QaTransportState["readMessage"];
|
||||
executeGenericAction: (params: {
|
||||
action: QaTransportActionName;
|
||||
args: Record<string, unknown>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) => Promise<unknown>;
|
||||
waitForReady: (params: {
|
||||
gateway: QaTransportGatewayClient;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}) => Promise<void>;
|
||||
waitForCondition: <T>(
|
||||
check: () => T | Promise<T | null | undefined> | null | undefined,
|
||||
timeoutMs?: number,
|
||||
intervalMs?: number,
|
||||
) => Promise<T>;
|
||||
assertNoFailureReplies: (options?: QaTransportFailureAssertionOptions) => void;
|
||||
};
|
||||
|
||||
export async function waitForQaTransportCondition<T>(
|
||||
check: () => T | Promise<T | null | undefined> | null | undefined,
|
||||
timeoutMs = 15_000,
|
||||
@@ -207,15 +181,19 @@ export type QaTransportAdapter = {
|
||||
requiredPluginIds: readonly string[];
|
||||
supportedActions: readonly QaTransportActionName[];
|
||||
state: QaTransportState;
|
||||
capabilities: QaTransportCapabilities;
|
||||
reset: () => Promise<void>;
|
||||
sendInbound: (input: QaBusInboundMessageInput) => Promise<QaBusMessage>;
|
||||
sendNativeCommand: (input: QaTransportNativeCommandInput) => Promise<void>;
|
||||
sendNativeCommand?: (input: QaTransportNativeCommandInput) => Promise<void>;
|
||||
waitForNoOutbound: (input?: QaTransportWaitForNoOutboundInput) => Promise<void>;
|
||||
waitForOutbound: (input: QaTransportOutboundMatch) => Promise<QaBusMessage>;
|
||||
waitForOutboundSequence: (
|
||||
waitForOutboundSequence?: (
|
||||
input: QaTransportOutboundSequenceMatch,
|
||||
) => Promise<QaTransportOutboundSequence>;
|
||||
waitForCondition: <T>(
|
||||
check: () => T | Promise<T | null | undefined> | null | undefined,
|
||||
timeoutMs?: number,
|
||||
intervalMs?: number,
|
||||
) => Promise<T>;
|
||||
createGatewayConfig: (params: { baseUrl: string }) => QaTransportGatewayConfig;
|
||||
waitReady: (params: {
|
||||
gateway: QaTransportGatewayClient;
|
||||
@@ -246,7 +224,7 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte
|
||||
readonly requiredPluginIds: readonly string[];
|
||||
readonly supportedActions: readonly QaTransportActionName[];
|
||||
readonly state: QaTransportState;
|
||||
readonly capabilities: QaTransportCapabilities;
|
||||
readonly waitForCondition: QaTransportAdapter["waitForCondition"];
|
||||
|
||||
protected constructor(params: {
|
||||
id: string;
|
||||
@@ -262,22 +240,7 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte
|
||||
this.requiredPluginIds = params.requiredPluginIds;
|
||||
this.supportedActions = params.supportedActions ?? [];
|
||||
this.state = params.state;
|
||||
this.capabilities = {
|
||||
sendInboundMessage: this.state.addInboundMessage.bind(this.state),
|
||||
injectOutboundMessage: this.state.addOutboundMessage.bind(this.state),
|
||||
waitForOutboundMessage: this.state.waitFor.bind(this.state),
|
||||
getNormalizedMessageState: this.state.getSnapshot.bind(this.state),
|
||||
resetNormalizedMessageState: async () => {
|
||||
await this.state.reset();
|
||||
},
|
||||
readNormalizedMessage: this.state.readMessage.bind(this.state),
|
||||
executeGenericAction: (paramsValue) => this.handleAction(paramsValue),
|
||||
waitForReady: (paramsLocal) => this.waitReady(paramsLocal),
|
||||
waitForCondition: createFailureAwareTransportWaitForCondition(this.state),
|
||||
assertNoFailureReplies: (options) => {
|
||||
assertNoFailureReplies(this.state, options);
|
||||
},
|
||||
};
|
||||
this.waitForCondition = createFailureAwareTransportWaitForCondition(this.state);
|
||||
}
|
||||
|
||||
abstract createGatewayConfig: (params: { baseUrl: string }) => QaTransportGatewayConfig;
|
||||
@@ -308,10 +271,6 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte
|
||||
return await this.state.addInboundMessage(input);
|
||||
}
|
||||
|
||||
async sendNativeCommand(_input: QaTransportNativeCommandInput): Promise<void> {
|
||||
throw new Error(`${this.label} does not support native commands.`);
|
||||
}
|
||||
|
||||
async waitForNoOutbound(input: QaTransportWaitForNoOutboundInput = {}) {
|
||||
const quietMs = resolveTimerTimeoutMs(input.quietMs, 1_200, 0);
|
||||
await sleep(quietMs);
|
||||
@@ -350,13 +309,6 @@ export abstract class QaStateBackedTransportAdapter implements QaTransportAdapte
|
||||
}, input.timeoutMs);
|
||||
}
|
||||
|
||||
async waitForOutboundSequence(input: QaTransportOutboundSequenceMatch) {
|
||||
return await waitForQaTransportOutboundSequence({
|
||||
input,
|
||||
readEvents: () => this.state.getSnapshot().events,
|
||||
});
|
||||
}
|
||||
|
||||
private outboundSince(sinceIndex = 0) {
|
||||
return this.state
|
||||
.getSnapshot()
|
||||
|
||||
@@ -19,6 +19,7 @@ function formatTestTranscript(state: ReturnType<typeof createQaBusState>) {
|
||||
async function runLoadedScenarioFlow(
|
||||
scenarioId: string,
|
||||
params: {
|
||||
omitOutboundSequence?: boolean;
|
||||
onWaitForOutboundMessage?: (params: {
|
||||
waitCount: number;
|
||||
state: ReturnType<typeof createQaBusState>;
|
||||
@@ -74,12 +75,16 @@ async function runLoadedScenarioFlow(
|
||||
}
|
||||
throw new Error(`timed out after ${input.timeoutMs}ms waiting for outbound marker`);
|
||||
},
|
||||
waitForOutboundSequence: async () => {
|
||||
throw new Error("outbound sequence not configured for this fixture");
|
||||
},
|
||||
...(params.omitOutboundSequence
|
||||
? {}
|
||||
: {
|
||||
waitForOutboundSequence: async () => {
|
||||
throw new Error("outbound sequence not configured for this fixture");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const api = {
|
||||
env: {},
|
||||
env: { providerMode: "mock-openai" },
|
||||
transport,
|
||||
state,
|
||||
scenario,
|
||||
@@ -87,6 +92,7 @@ async function runLoadedScenarioFlow(
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
liveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs,
|
||||
waitForGatewayHealthy: async () => undefined,
|
||||
waitForTransportReady: async () => undefined,
|
||||
waitForQaChannelReady: async () => undefined,
|
||||
waitForNoOutbound: async () => undefined,
|
||||
sleep: async () => undefined,
|
||||
@@ -141,6 +147,16 @@ async function runLoadedScenarioFlow(
|
||||
}
|
||||
|
||||
describe("scenario-flow-runner", () => {
|
||||
it("fails when a flow calls a transport method the adapter does not implement", async () => {
|
||||
await expect(
|
||||
runLoadedScenarioFlow("channel-message-flows", {
|
||||
omitOutboundSequence: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'QA scenario "channel-message-flows" cannot run "waitForOutboundSequence": the active transport adapter does not implement this method.',
|
||||
);
|
||||
});
|
||||
|
||||
it("supports qaImport inside flow expressions", async () => {
|
||||
const result = await runScenarioFlow({
|
||||
api: {
|
||||
|
||||
@@ -141,6 +141,12 @@ async function resolveValue(node: unknown, api: QaFlowApi, vars: QaFlowVars): Pr
|
||||
function resolveCallable(path: string, api: QaFlowApi, vars: QaFlowVars) {
|
||||
const { parent, value } = getPathWithParent(createEvalContext(api, vars), path);
|
||||
if (typeof value !== "function") {
|
||||
if (path.startsWith("transport.")) {
|
||||
const method = path.slice("transport.".length);
|
||||
throw new Error(
|
||||
`QA scenario "${api.scenario.id}" cannot run "${method}": the active transport adapter does not implement this method.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`qa flow callable not found: ${path}`);
|
||||
}
|
||||
return parent ? value.bind(parent) : value;
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import type { QaTransportCapabilities } from "./qa-transport.js";
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
import {
|
||||
createQaScenarioRuntimeApi,
|
||||
type QaScenarioRuntimeConstants,
|
||||
@@ -113,13 +113,13 @@ const browserAndWebRuntimeTools = [
|
||||
] as const;
|
||||
|
||||
describe("createQaScenarioRuntimeApi", () => {
|
||||
it("builds a markdown-flow runtime surface from generic transport capabilities", async () => {
|
||||
it("builds a markdown-flow runtime surface from the transport adapter", async () => {
|
||||
const state = createQaBusState();
|
||||
const resetSpy = vi.spyOn(state, "reset");
|
||||
const inboundSpy = vi.spyOn(state, "addInboundMessage");
|
||||
const outboundSpy = vi.spyOn(state, "addOutboundMessage");
|
||||
const readSpy = vi.spyOn(state, "readMessage");
|
||||
const waitForCondition: QaTransportCapabilities["waitForCondition"] = async <T>(
|
||||
const waitForCondition: QaTransportAdapter["waitForCondition"] = async <T>(
|
||||
check: () => T | Promise<T | null | undefined> | null | undefined,
|
||||
): Promise<T> => {
|
||||
const value = await check();
|
||||
@@ -157,20 +157,7 @@ describe("createQaScenarioRuntimeApi", () => {
|
||||
waitForOutboundSequence: vi.fn(async () => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
capabilities: {
|
||||
waitForCondition,
|
||||
getNormalizedMessageState: state.getSnapshot.bind(state),
|
||||
resetNormalizedMessageState: async () => {
|
||||
state.reset();
|
||||
},
|
||||
sendInboundMessage: state.addInboundMessage.bind(state),
|
||||
injectOutboundMessage: state.addOutboundMessage.bind(state),
|
||||
waitForOutboundMessage: state.waitFor.bind(state),
|
||||
readNormalizedMessage: state.readMessage.bind(state),
|
||||
executeGenericAction: vi.fn(async () => undefined),
|
||||
waitForReady: vi.fn(async () => undefined),
|
||||
assertNoFailureReplies: vi.fn(),
|
||||
},
|
||||
waitForCondition,
|
||||
},
|
||||
};
|
||||
const scenario = {
|
||||
|
||||
@@ -8,13 +8,13 @@ type QaScenarioRuntimeFunction = (...args: never[]) => unknown;
|
||||
|
||||
type QaScenarioTransport = Pick<
|
||||
QaTransportAdapter,
|
||||
| "capabilities"
|
||||
| "reset"
|
||||
| "sendInbound"
|
||||
| "sendNativeCommand"
|
||||
| "state"
|
||||
| "waitForNoOutbound"
|
||||
| "waitForOutbound"
|
||||
| "waitForCondition"
|
||||
>;
|
||||
|
||||
export type QaScenarioRuntimeEnv<
|
||||
@@ -124,7 +124,7 @@ type QaScenarioRuntimeApi<
|
||||
sleep: (ms?: number) => Promise<unknown>;
|
||||
randomUUID: () => string;
|
||||
runScenario: TDeps["runScenario"];
|
||||
waitForCondition: TEnv["transport"]["capabilities"]["waitForCondition"];
|
||||
waitForCondition: TEnv["transport"]["waitForCondition"];
|
||||
waitForOutboundMessage: TDeps["waitForOutboundMessage"];
|
||||
waitForTransportOutboundMessage: TDeps["waitForTransportOutboundMessage"];
|
||||
waitForChannelOutboundMessage: TDeps["waitForChannelOutboundMessage"];
|
||||
@@ -199,11 +199,11 @@ type QaScenarioRuntimeApi<
|
||||
imageUnderstandingPngBase64: string;
|
||||
imageUnderstandingLargePngBase64: string;
|
||||
imageUnderstandingValidPngBase64: string;
|
||||
getTransportSnapshot: TEnv["transport"]["capabilities"]["getNormalizedMessageState"];
|
||||
getTransportSnapshot: TEnv["transport"]["state"]["getSnapshot"];
|
||||
resetTransport: () => Promise<void>;
|
||||
injectInboundMessage: TEnv["transport"]["capabilities"]["sendInboundMessage"];
|
||||
injectOutboundMessage: TEnv["transport"]["capabilities"]["injectOutboundMessage"];
|
||||
readTransportMessage: TEnv["transport"]["capabilities"]["readNormalizedMessage"];
|
||||
injectInboundMessage: TEnv["transport"]["state"]["addInboundMessage"];
|
||||
injectOutboundMessage: TEnv["transport"]["state"]["addOutboundMessage"];
|
||||
readTransportMessage: TEnv["transport"]["state"]["readMessage"];
|
||||
resetBus: () => Promise<void>;
|
||||
reset: () => Promise<void>;
|
||||
};
|
||||
@@ -217,16 +217,18 @@ export function createQaScenarioRuntimeApi<
|
||||
deps: TDeps;
|
||||
constants: QaScenarioRuntimeConstants;
|
||||
}): QaScenarioRuntimeApi<TEnv, TDeps> {
|
||||
const transport = params.env.transport;
|
||||
const transportState = transport.state;
|
||||
const resetTransportState = async () => {
|
||||
await params.env.transport.capabilities.resetNormalizedMessageState();
|
||||
await transport.reset();
|
||||
await params.deps.sleep(100);
|
||||
};
|
||||
|
||||
return {
|
||||
env: params.env,
|
||||
lab: params.env.lab,
|
||||
transport: params.env.transport,
|
||||
state: params.env.transport.state,
|
||||
transport,
|
||||
state: transportState,
|
||||
scenario: params.scenario,
|
||||
config: params.scenario.execution.config ?? {},
|
||||
fs: params.deps.fs,
|
||||
@@ -234,7 +236,7 @@ export function createQaScenarioRuntimeApi<
|
||||
sleep: params.deps.sleep,
|
||||
randomUUID: params.deps.randomUUID,
|
||||
runScenario: params.deps.runScenario,
|
||||
waitForCondition: params.env.transport.capabilities.waitForCondition,
|
||||
waitForCondition: transport.waitForCondition,
|
||||
waitForOutboundMessage: params.deps.waitForOutboundMessage,
|
||||
waitForTransportOutboundMessage: params.deps.waitForTransportOutboundMessage,
|
||||
waitForChannelOutboundMessage: params.deps.waitForChannelOutboundMessage,
|
||||
@@ -309,11 +311,11 @@ export function createQaScenarioRuntimeApi<
|
||||
imageUnderstandingPngBase64: params.constants.imageUnderstandingPngBase64,
|
||||
imageUnderstandingLargePngBase64: params.constants.imageUnderstandingLargePngBase64,
|
||||
imageUnderstandingValidPngBase64: params.constants.imageUnderstandingValidPngBase64,
|
||||
getTransportSnapshot: params.env.transport.capabilities.getNormalizedMessageState,
|
||||
getTransportSnapshot: transportState.getSnapshot.bind(transportState),
|
||||
resetTransport: resetTransportState,
|
||||
injectInboundMessage: params.env.transport.capabilities.sendInboundMessage,
|
||||
injectOutboundMessage: params.env.transport.capabilities.injectOutboundMessage,
|
||||
readTransportMessage: params.env.transport.capabilities.readNormalizedMessage,
|
||||
injectInboundMessage: transportState.addInboundMessage.bind(transportState),
|
||||
injectOutboundMessage: transportState.addOutboundMessage.bind(transportState),
|
||||
readTransportMessage: transportState.readMessage.bind(transportState),
|
||||
resetBus: resetTransportState,
|
||||
reset: resetTransportState,
|
||||
};
|
||||
|
||||
@@ -41,10 +41,13 @@ export async function runQaSelfCheckAgainstState(params: {
|
||||
waitTimeoutMs?: number;
|
||||
}): Promise<QaSelfCheckResult> {
|
||||
const startedAt = new Date();
|
||||
const transport = createQaTransportAdapter({
|
||||
id: params.transportId ?? "qa-channel",
|
||||
const transportFactoryResult = await createQaTransportAdapter({
|
||||
channelId: params.transportId ?? "qa-channel",
|
||||
driver: params.transportId ?? "qa-channel",
|
||||
outputDir: path.dirname(resolveQaSelfCheckOutputPath(params)),
|
||||
state: params.state,
|
||||
});
|
||||
const transport = transportFactoryResult.adapter;
|
||||
params.state.reset();
|
||||
const scenarioResult = await runQaScenario(
|
||||
createQaSelfCheckScenario({ waitTimeoutMs: params.waitTimeoutMs }),
|
||||
@@ -104,6 +107,7 @@ export async function runQaSelfCheckAgainstState(params: {
|
||||
});
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, report, "utf8");
|
||||
await transportFactoryResult.cleanup();
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
|
||||
@@ -194,18 +194,7 @@ describe("qa suite runtime flow", () => {
|
||||
searchMessages: vi.fn(),
|
||||
waitFor: vi.fn(),
|
||||
},
|
||||
capabilities: {
|
||||
waitForOutboundMessage: vi.fn(),
|
||||
waitForCondition: vi.fn(),
|
||||
getNormalizedMessageState: vi.fn(),
|
||||
resetNormalizedMessageState: vi.fn(),
|
||||
sendInboundMessage: vi.fn(),
|
||||
injectOutboundMessage: vi.fn(),
|
||||
readNormalizedMessage: vi.fn(),
|
||||
executeGenericAction: vi.fn(),
|
||||
waitForReady: vi.fn(),
|
||||
assertNoFailureReplies: vi.fn(),
|
||||
},
|
||||
waitForCondition: vi.fn(),
|
||||
},
|
||||
repoRoot: "/repo",
|
||||
providerMode: "mock-openai",
|
||||
|
||||
@@ -116,22 +116,22 @@ export type QaSuiteStartLabFn = (params?: QaLabServerStartParams) => Promise<QaL
|
||||
|
||||
async function createQaSuiteTransportAdapter(params: {
|
||||
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
|
||||
cleanupOnFailure?: () => Promise<void>;
|
||||
outputDir: string;
|
||||
state: QaLabServerHandle["state"];
|
||||
transportId: QaTransportId;
|
||||
}) {
|
||||
if (params.channelDriverSelection) {
|
||||
const { createQaCrablineTransportAdapter } = await import("./crabline-transport.js");
|
||||
return await createQaCrablineTransportAdapter({
|
||||
try {
|
||||
return await createQaTransportAdapter({
|
||||
channelId: params.channelDriverSelection?.channel ?? params.transportId,
|
||||
driver: params.channelDriverSelection ? "crabline" : params.transportId,
|
||||
outputDir: params.outputDir,
|
||||
selection: params.channelDriverSelection,
|
||||
state: params.state,
|
||||
});
|
||||
} catch (error) {
|
||||
await params.cleanupOnFailure?.().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return createQaTransportAdapter({
|
||||
id: params.transportId,
|
||||
state: params.state,
|
||||
});
|
||||
}
|
||||
|
||||
export type QaSuiteRunParams = {
|
||||
@@ -700,12 +700,14 @@ async function runQaRuntimeParitySuite(params: {
|
||||
port: 0,
|
||||
embeddedGateway: "disabled",
|
||||
}));
|
||||
const transport = await createQaSuiteTransportAdapter({
|
||||
const transportFactoryResult = await createQaSuiteTransportAdapter({
|
||||
channelDriverSelection: params.channelDriverSelection,
|
||||
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
|
||||
outputDir: params.outputDir,
|
||||
state: lab.state,
|
||||
transportId: params.transportId,
|
||||
});
|
||||
const transport = transportFactoryResult.adapter;
|
||||
const liveScenarioOutcomes: QaLabScenarioOutcome[] = params.selectedScenarios.map((scenario) => ({
|
||||
id: scenario.id,
|
||||
name: scenario.title,
|
||||
@@ -893,7 +895,7 @@ async function runQaRuntimeParitySuite(params: {
|
||||
watchUrl: lab.baseUrl,
|
||||
} satisfies QaSuiteResult;
|
||||
} finally {
|
||||
await transport.cleanup?.();
|
||||
await transportFactoryResult.cleanup();
|
||||
if (ownsLab) {
|
||||
await lab.stop();
|
||||
}
|
||||
@@ -1263,12 +1265,14 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
port: 0,
|
||||
embeddedGateway: "disabled",
|
||||
}));
|
||||
const transport = await createQaSuiteTransportAdapter({
|
||||
const transportFactoryResult = await createQaSuiteTransportAdapter({
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
|
||||
outputDir,
|
||||
state: lab.state,
|
||||
transportId,
|
||||
});
|
||||
const transport = transportFactoryResult.adapter;
|
||||
const liveScenarioOutcomes: QaLabScenarioOutcome[] = selectedScenarios.map((scenario) => ({
|
||||
id: scenario.id,
|
||||
name: scenario.title,
|
||||
@@ -1500,7 +1504,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
watchUrl: lab.baseUrl,
|
||||
} satisfies QaSuiteResult;
|
||||
} finally {
|
||||
await transport.cleanup?.();
|
||||
await transportFactoryResult.cleanup();
|
||||
await disposeRegisteredAgentHarnesses();
|
||||
if (ownsLab) {
|
||||
await lab.stop();
|
||||
@@ -1521,81 +1525,89 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
}));
|
||||
writeQaSuiteProgress(progressEnabled, `lab ready: ${sanitizeQaSuiteProgressValue(lab.baseUrl)}`);
|
||||
await waitForQaLabReadyOrStopOwned({ lab, ownsLab });
|
||||
const transport = await createQaSuiteTransportAdapter({
|
||||
const transportFactoryResult = await createQaSuiteTransportAdapter({
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
|
||||
outputDir,
|
||||
state: lab.state,
|
||||
transportId,
|
||||
});
|
||||
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
|
||||
const mock = await startQaProviderServer(providerMode);
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`provider ready: ${sanitizeQaSuiteProgressValue(mock?.baseUrl ?? "live")}`,
|
||||
);
|
||||
writeQaSuiteProgress(progressEnabled, "gateway start");
|
||||
const gateway = await startQaGatewayChild({
|
||||
repoRoot,
|
||||
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
|
||||
transport,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
controlUiAllowedOrigins: [lab.listenUrl],
|
||||
providerMode,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
fastMode,
|
||||
thinkingDefault: params?.thinkingDefault,
|
||||
claudeCliAuthMode: params?.claudeCliAuthMode,
|
||||
controlUiEnabled: params?.controlUiEnabled ?? true,
|
||||
enabledPluginIds,
|
||||
forwardHostHome: gatewayRuntimeOptions?.forwardHostHome,
|
||||
mutateConfig: gatewayConfigPatch
|
||||
? (cfg) => applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig
|
||||
: undefined,
|
||||
runtimeEnvPatch: mergeQaRuntimeEnvPatches(
|
||||
buildQaRuntimeEnvPatch({
|
||||
providerMode,
|
||||
forcedRuntime: params?.forcedRuntime,
|
||||
mockBaseUrl: mock?.baseUrl,
|
||||
}),
|
||||
transport.createRuntimeEnvPatch?.(),
|
||||
buildQaGatewayHeapCheckpointRuntimeEnvPatch(),
|
||||
),
|
||||
});
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`gateway ready: ${sanitizeQaSuiteProgressValue(gateway.baseUrl)}`,
|
||||
);
|
||||
lab.setControlUi({
|
||||
controlUiProxyTarget: gateway.baseUrl,
|
||||
controlUiProxyToken: gateway.token,
|
||||
});
|
||||
const env: QaSuiteEnvironment = {
|
||||
lab,
|
||||
mock,
|
||||
gateway,
|
||||
// YAML scenarios should see the full staged gateway config, not just
|
||||
// the transport fragment. Routing/session/plugin assertions depend on it.
|
||||
cfg: gateway.cfg,
|
||||
transport,
|
||||
repoRoot,
|
||||
providerMode,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
webSessionIds: new Set(),
|
||||
};
|
||||
|
||||
const transport = transportFactoryResult.adapter;
|
||||
let mock: Awaited<ReturnType<typeof startQaProviderServer>> | undefined;
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let env: QaSuiteEnvironment | undefined;
|
||||
let preserveGatewayRuntimeDir: string | undefined;
|
||||
try {
|
||||
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
|
||||
const activeMock = await startQaProviderServer(providerMode);
|
||||
mock = activeMock;
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`provider ready: ${sanitizeQaSuiteProgressValue(activeMock?.baseUrl ?? "live")}`,
|
||||
);
|
||||
writeQaSuiteProgress(progressEnabled, "gateway start");
|
||||
const activeGateway = await startQaGatewayChild({
|
||||
repoRoot,
|
||||
providerBaseUrl: activeMock ? `${activeMock.baseUrl}/v1` : undefined,
|
||||
transport,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
controlUiAllowedOrigins: [lab.listenUrl],
|
||||
providerMode,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
fastMode,
|
||||
thinkingDefault: params?.thinkingDefault,
|
||||
claudeCliAuthMode: params?.claudeCliAuthMode,
|
||||
controlUiEnabled: params?.controlUiEnabled ?? true,
|
||||
enabledPluginIds,
|
||||
forwardHostHome: gatewayRuntimeOptions?.forwardHostHome,
|
||||
mutateConfig: gatewayConfigPatch
|
||||
? (cfg) => applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig
|
||||
: undefined,
|
||||
runtimeEnvPatch: mergeQaRuntimeEnvPatches(
|
||||
buildQaRuntimeEnvPatch({
|
||||
providerMode,
|
||||
forcedRuntime: params?.forcedRuntime,
|
||||
mockBaseUrl: activeMock?.baseUrl,
|
||||
}),
|
||||
transport.createRuntimeEnvPatch?.(),
|
||||
buildQaGatewayHeapCheckpointRuntimeEnvPatch(),
|
||||
),
|
||||
});
|
||||
gateway = activeGateway;
|
||||
writeQaSuiteProgress(
|
||||
progressEnabled,
|
||||
`gateway ready: ${sanitizeQaSuiteProgressValue(activeGateway.baseUrl)}`,
|
||||
);
|
||||
lab.setControlUi({
|
||||
controlUiProxyTarget: activeGateway.baseUrl,
|
||||
controlUiProxyToken: activeGateway.token,
|
||||
});
|
||||
const activeEnv: QaSuiteEnvironment = {
|
||||
lab,
|
||||
mock: activeMock,
|
||||
gateway: activeGateway,
|
||||
// YAML scenarios should see the full staged gateway config, not just
|
||||
// the transport fragment. Routing/session/plugin assertions depend on it.
|
||||
cfg: activeGateway.cfg,
|
||||
transport,
|
||||
repoRoot,
|
||||
providerMode,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
webSessionIds: new Set(),
|
||||
};
|
||||
env = activeEnv;
|
||||
|
||||
const transportReadyTimeoutMs = resolveQaSuiteTransportReadyTimeoutMs(
|
||||
params?.transportReadyTimeoutMs,
|
||||
);
|
||||
// The gateway child already waits for /readyz before returning, but the
|
||||
// selected transport can still be finishing account startup. Pay that
|
||||
// readiness cost once here so the first scenario does not race bootstrap.
|
||||
await waitForTransportReady(env, transportReadyTimeoutMs).catch(async () => {
|
||||
await waitForGatewayHealthy(env, transportReadyTimeoutMs);
|
||||
await waitForTransportReady(env, transportReadyTimeoutMs);
|
||||
await waitForTransportReady(activeEnv, transportReadyTimeoutMs).catch(async () => {
|
||||
await waitForGatewayHealthy(activeEnv, transportReadyTimeoutMs);
|
||||
await waitForTransportReady(activeEnv, transportReadyTimeoutMs);
|
||||
});
|
||||
await sleep(1_000);
|
||||
const scenarios: QaSuiteScenarioResult[] = [];
|
||||
@@ -1614,7 +1626,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
|
||||
const gatewayProcessRssSamples: QaSuiteGatewayRssSample[] = [];
|
||||
const sampleGatewayProcessRss = (label: string) => {
|
||||
const gatewayProcessRssBytes = gateway.getProcessRssBytes?.() ?? null;
|
||||
const gatewayProcessRssBytes = activeGateway.getProcessRssBytes?.() ?? null;
|
||||
if (gatewayProcessRssBytes !== null) {
|
||||
gatewayProcessRssSamples.push({
|
||||
label,
|
||||
@@ -1624,7 +1636,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
}
|
||||
return gatewayProcessRssBytes;
|
||||
};
|
||||
const gatewayProcessCpuStartMs = gateway.getProcessCpuMs?.() ?? null;
|
||||
const gatewayProcessCpuStartMs = activeGateway.getProcessCpuMs?.() ?? null;
|
||||
const gatewayProcessRssStartBytes = sampleGatewayProcessRss("suite-start");
|
||||
const gatewayHeapSnapshots: QaSuiteGatewayHeapSnapshot[] = [];
|
||||
const captureGatewayHeapCheckpoint = async (label: string) => {
|
||||
@@ -1632,7 +1644,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
return;
|
||||
}
|
||||
const snapshot = await captureGatewayHeapSnapshotCheckpoint({
|
||||
gateway,
|
||||
gateway: activeGateway,
|
||||
outputDir,
|
||||
label,
|
||||
});
|
||||
@@ -1661,7 +1673,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
scenarios: [...liveScenarioOutcomes],
|
||||
});
|
||||
|
||||
const result = await runScenarioDefinition(env, scenario);
|
||||
const result = await runScenarioDefinition(activeEnv, scenario);
|
||||
sampleGatewayProcessRss(`scenario:${scenario.id}:finish`);
|
||||
scenarios.push(result);
|
||||
writeQaSuiteProgress(
|
||||
@@ -1692,10 +1704,10 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
scenarios.length > 0
|
||||
? await captureRuntimeParityCell({
|
||||
runtime: params.forcedRuntime,
|
||||
gateway,
|
||||
gateway: activeGateway,
|
||||
scenarioResult: scenarios[0],
|
||||
wallClockMs: Math.max(1, Date.now() - startedAt.getTime()),
|
||||
mockBaseUrl: mock?.baseUrl,
|
||||
mockBaseUrl: activeMock?.baseUrl,
|
||||
})
|
||||
: undefined;
|
||||
const finishedAt = new Date();
|
||||
@@ -1704,7 +1716,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
startedAt,
|
||||
finishedAt,
|
||||
gatewayProcessCpuStartMs,
|
||||
gatewayProcessCpuEndMs: gateway.getProcessCpuMs?.() ?? null,
|
||||
gatewayProcessCpuEndMs: activeGateway.getProcessCpuMs?.() ?? null,
|
||||
gatewayProcessRssStartBytes,
|
||||
gatewayProcessRssEndBytes: sampleGatewayProcessRss("suite-finish"),
|
||||
gatewayProcessRssSamples,
|
||||
@@ -1778,13 +1790,15 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
|
||||
throw error;
|
||||
} finally {
|
||||
await closeQaWebSessions(env.webSessionIds);
|
||||
if (env) {
|
||||
await closeQaWebSessions(env.webSessionIds);
|
||||
}
|
||||
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
|
||||
await gateway.stop({
|
||||
await gateway?.stop({
|
||||
keepTemp,
|
||||
preserveToDir: keepTemp ? undefined : preserveGatewayRuntimeDir,
|
||||
});
|
||||
await transport.cleanup?.();
|
||||
await transportFactoryResult.cleanup();
|
||||
await disposeRegisteredAgentHarnesses();
|
||||
await mock?.stop();
|
||||
if (ownsLab) {
|
||||
|
||||
Reference in New Issue
Block a user