refactor(qa): remove live-channel scenario registries and wrappers (#115752)

* refactor(qa): remove duplicate channel scenario registries

* refactor(qa): route live scenarios through shared runners

* fix(qa): isolate WhatsApp driver retries

* refactor(qa): add module export flow arguments

* test(qa): include scenario metadata in matrix fixture
This commit is contained in:
Dallin Romney
2026-07-30 13:56:34 +08:00
committed by GitHub
parent 47a21192fd
commit 7df05d5fc0
100 changed files with 2740 additions and 2809 deletions
@@ -2,7 +2,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discordQaScenarioSupport } from "./discord-live.runtime.js";
import { resolveDiscordQaScenarioIds } from "./scenario-selection.js";
const { testing } = discordQaScenarioSupport;
@@ -285,23 +284,6 @@ describe("discord live qa runtime", () => {
expect(testing.computeDiscordRttMs("bad", "2026-04-22T12:00:00.875Z")).toBeUndefined();
});
it("includes the Discord live scenarios", () => {
expect(testing.findScenario().map((scenario) => scenario.id)).toEqual(
resolveDiscordQaScenarioIds({}),
);
expect(
testing.findScenario(["discord-status-reactions-tool-only"]).map((scenario) => scenario.id),
).toEqual(["discord-status-reactions-tool-only"]);
expect(testing.findScenario(["discord-voice-autojoin"]).map((scenario) => scenario.id)).toEqual(
["discord-voice-autojoin"],
);
expect(
testing
.findScenario(["discord-thread-reply-filepath-attachment"])
.map((scenario) => scenario.id),
).toEqual(["discord-thread-reply-filepath-attachment"]);
});
it("collects the status reaction sequence across timeline snapshots", () => {
expect(
testing.collectSeenReactionSequence(
@@ -462,12 +444,6 @@ describe("discord live qa runtime", () => {
}
});
it("fails when any requested Discord scenario id is unknown", () => {
expect(() => testing.findScenario(["discord-canary", "typo-scenario"])).toThrow(
"unknown QA scenario id(s): typo-scenario",
);
});
it("lists Discord application commands through the REST API", async () => {
vi.stubGlobal(
"fetch",
@@ -19,7 +19,6 @@ import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
import { assertLiveScenarioReply as assertDiscordScenarioReply } from "../shared/live-scenario-reply.js";
import { resolveDiscordQaScenarioIds } from "./scenario-selection.js";
type DiscordQaRuntimeEnv = {
guildId: string;
@@ -30,15 +29,7 @@ type DiscordQaRuntimeEnv = {
voiceChannelId?: string;
};
type DiscordQaScenarioId =
| "discord-canary"
| "discord-mention-gating"
| "discord-native-help-command-registration"
| "discord-voice-autojoin"
| "discord-thread-reply-filepath-attachment"
| "discord-status-reactions-tool-only";
type DiscordQaScenarioRun =
export type DiscordQaScenarioRun =
| {
kind: "channel-message";
expectReply: boolean;
@@ -65,13 +56,16 @@ type DiscordQaScenarioRun =
replyContent: string;
};
type DiscordQaScenarioDefinition = {
id: DiscordQaScenarioId;
title: string;
timeoutMs: number;
export type DiscordQaScenarioImplementation = {
buildRun: (sutApplicationId: string) => DiscordQaScenarioRun;
};
type DiscordQaScenarioMetadata = {
id: string;
timeoutMs: number;
title: string;
};
type DiscordUser = {
id: string;
username?: string;
@@ -196,7 +190,7 @@ type DiscordReactionSnapshot = {
type DiscordStatusReactionTimeline = {
expectedSequence: string[];
htmlPath?: string;
scenarioId: DiscordQaScenarioId;
scenarioId: string;
scenarioTitle: string;
screenshotPath?: string;
screenshotWarning?: string;
@@ -215,7 +209,7 @@ type DiscordThreadReplyAttachmentEvidence = {
messageContent?: string;
messageId?: string;
parentMessageId?: string;
scenarioId: DiscordQaScenarioId;
scenarioId: string;
scenarioTitle: string;
screenshotPath?: string;
screenshotWarning?: string;
@@ -234,85 +228,70 @@ const DISCORD_QA_ENV_KEYS = [
"OPENCLAW_QA_DISCORD_SUT_APPLICATION_ID",
] as const;
const DISCORD_QA_SCENARIOS: DiscordQaScenarioDefinition[] = [
{
id: "discord-canary",
title: "Discord canary echo",
timeoutMs: 45_000,
buildRun: (sutApplicationId) => {
const token = `DISCORD_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "channel-message",
expectReply: true,
input: `<@${sutApplicationId}> reply with only this exact marker: ${token}`,
expectedTextIncludes: [token],
matchText: token,
};
},
export const discordQaCanaryScenario: DiscordQaScenarioImplementation = {
buildRun: (sutApplicationId) => {
const token = `DISCORD_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "channel-message",
expectReply: true,
input: `<@${sutApplicationId}> reply with only this exact marker: ${token}`,
expectedTextIncludes: [token],
matchText: token,
};
},
{
id: "discord-mention-gating",
title: "Discord unmentioned message does not trigger",
timeoutMs: 8_000,
buildRun: () => {
const token = `DISCORD_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "channel-message",
expectReply: false,
input: `reply with only this exact marker: ${token}`,
matchText: token,
};
},
};
export const discordQaMentionGatingScenario: DiscordQaScenarioImplementation = {
buildRun: () => {
const token = `DISCORD_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "channel-message",
expectReply: false,
input: `reply with only this exact marker: ${token}`,
matchText: token,
};
},
{
id: "discord-native-help-command-registration",
title: "Discord native help command is registered",
timeoutMs: 45_000,
buildRun: () => ({
kind: "application-command-registration",
expectedCommandNames: ["help"],
}),
};
export const discordQaNativeHelpCommandRegistrationScenario: DiscordQaScenarioImplementation = {
buildRun: () => ({
kind: "application-command-registration",
expectedCommandNames: ["help"],
}),
};
export const discordQaVoiceAutojoinScenario: DiscordQaScenarioImplementation = {
buildRun: () => ({
kind: "voice-autojoin",
}),
};
export const discordQaStatusReactionsToolOnlyScenario: DiscordQaScenarioImplementation = {
buildRun: () => {
const token = `DISCORD_QA_STATUS_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "status-reactions-tool-only",
input: [
`Mantis status reaction QA marker ${token}.`,
"Think briefly, then reply with only this exact marker:",
token,
].join(" "),
expectedSequence: ["👀", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.done],
};
},
{
id: "discord-voice-autojoin",
title: "Discord voice auto-join connects",
timeoutMs: 60_000,
buildRun: () => ({
kind: "voice-autojoin",
}),
};
export const discordQaThreadReplyFilepathAttachmentScenario: DiscordQaScenarioImplementation = {
buildRun: () => {
const token = `DISCORD_QA_THREAD_FILE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "thread-reply-filepath-attachment",
input: `Mantis Discord thread attachment parent ${token}`,
replyContent: `Mantis thread attachment reply ${token}`,
expectedAttachmentFilename: "mantis-thread-report.md",
};
},
{
id: "discord-status-reactions-tool-only",
title: "Discord explicit status reactions run in tool-only reply mode",
timeoutMs: 75_000,
buildRun: () => {
const token = `DISCORD_QA_STATUS_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "status-reactions-tool-only",
input: [
`Mantis status reaction QA marker ${token}.`,
"Think briefly, then reply with only this exact marker:",
token,
].join(" "),
expectedSequence: ["👀", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.done],
};
},
},
{
id: "discord-thread-reply-filepath-attachment",
title: "Discord thread reply preserves filePath attachment",
timeoutMs: 45_000,
buildRun: () => {
const token = `DISCORD_QA_THREAD_FILE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
kind: "thread-reply-filepath-attachment",
input: `Mantis Discord thread attachment parent ${token}`,
replyContent: `Mantis thread attachment reply ${token}`,
expectedAttachmentFilename: "mantis-thread-report.md",
};
},
},
];
};
const discordQaCredentialPayloadSchema = z.object({
guildId: z.string().trim().min(1),
@@ -951,7 +930,7 @@ async function observeStatusReactionTimeline(params: {
channelId: string;
expectedSequence: string[];
messageId: string;
scenarioId: DiscordQaScenarioId;
scenarioId: string;
scenarioTitle: string;
timeoutMs: number;
token: string;
@@ -1117,7 +1096,7 @@ async function runDiscordThreadReplyFilePathAttachmentScenario(params: {
driverBotId: string;
outputDir: string;
runtimeEnv: DiscordQaRuntimeEnv;
scenario: DiscordQaScenarioDefinition;
scenario: DiscordQaScenarioMetadata;
scenarioRun: Extract<DiscordQaScenarioRun, { kind: "thread-reply-filepath-attachment" }>;
sutAccountId: string;
sutBotId: string;
@@ -1344,16 +1323,6 @@ function buildObservedMessagesArtifact(params: {
});
}
function findScenario(ids?: string[]) {
const requestedIds = resolveDiscordQaScenarioIds({ scenarioIds: ids });
const scenariosById = new Map(DISCORD_QA_SCENARIOS.map((scenario) => [scenario.id, scenario]));
const missingIds = requestedIds.filter((id) => !scenariosById.has(id as DiscordQaScenarioId));
if (missingIds.length > 0) {
throw new Error(`unknown Discord QA scenario id(s): ${missingIds.join(", ")}`);
}
return requestedIds.map((id) => scenariosById.get(id as DiscordQaScenarioId)!);
}
function matchesDiscordScenarioReply(params: {
channelId: string;
message: DiscordObservedMessage;
@@ -1401,7 +1370,6 @@ async function assertDiscordApplicationCommandsRegistered(params: {
}
const testing = {
DISCORD_QA_SCENARIOS,
collectSeenReactionSequence,
assertDiscordScenarioReply,
assertDiscordApplicationCommandsRegistered,
@@ -1409,7 +1377,6 @@ const testing = {
buildDiscordWebMessageUrl,
buildObservedMessagesArtifact,
computeDiscordRttMs,
findScenario,
getCurrentDiscordUser,
observeStatusReactionTimeline,
pollChannelMessages,
@@ -4,7 +4,11 @@ import {
patchLiveQaGatewayConfig,
readLiveQaGatewayConfig,
} from "../shared/live-gateway-config.runtime.js";
import { discordQaScenarioSupport } from "./discord-live.runtime.js";
import {
discordQaScenarioSupport,
type DiscordQaScenarioImplementation,
type DiscordQaScenarioRun,
} from "./discord-live.runtime.js";
type AdapterFactory = NonNullable<QaRunnerCliRegistration["adapterFactory"]>;
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>>;
@@ -18,18 +22,21 @@ type DiscordIdentity = Awaited<
type DiscordObservedMessage = Parameters<
typeof discordQaScenarioSupport.testing.pollChannelMessages
>[0]["observedMessages"][number];
export type DiscordQaScenarioEnvironment = {
cfg: OpenClawConfig;
configureScenario: (implementation: DiscordQaScenarioImplementation) => Promise<{
cfg: OpenClawConfig;
run: DiscordQaScenarioRun;
voiceChannel?: Awaited<
ReturnType<typeof discordQaScenarioSupport.testing.resolveDiscordQaVoiceChannel>
>;
}>;
driverIdentity: DiscordIdentity;
observedMessages: DiscordObservedMessage[];
outputDir: string;
runtimeEnv: DiscordRuntimeEnv;
scenario: { id: string; timeoutMs: number; title: string };
sutAccountId: string;
sutIdentity: DiscordIdentity;
voiceChannel?: Awaited<
ReturnType<typeof discordQaScenarioSupport.testing.resolveDiscordQaVoiceChannel>
>;
};
export function createDiscordQaScenarioEnvironment(params: {
@@ -40,61 +47,64 @@ export function createDiscordQaScenarioEnvironment(params: {
}) {
const observedMessages: DiscordObservedMessage[] = [];
const prepareFlow = async (input: FlowPreparationInput) => {
const scenarioId = input.config.discordScenarioId;
if (typeof scenarioId !== "string") {
throw new Error("Discord QA module flow requires config.discordScenarioId");
}
const scenario = discordQaScenarioSupport.testing.findScenario([scenarioId])[0];
if (!scenario) {
throw new Error(`unknown Discord QA scenario id: ${scenarioId}`);
}
const scenarioRun = scenario.buildRun(params.runtimeEnv.sutApplicationId);
const voiceChannel =
scenarioRun.kind === "voice-autojoin"
? await discordQaScenarioSupport.testing.resolveDiscordQaVoiceChannel({
guildId: params.runtimeEnv.guildId,
token: params.runtimeEnv.sutBotToken,
voiceChannelId: params.runtimeEnv.voiceChannelId,
})
: undefined;
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = discordQaScenarioSupport.testing.buildDiscordQaConfig(
snapshot.config as OpenClawConfig,
{
guildId: params.runtimeEnv.guildId,
channelId: params.runtimeEnv.channelId,
driverBotId: params.driverIdentity.id,
sutAccountId: params.accountId,
sutBotToken: params.runtimeEnv.sutBotToken,
},
{
...(voiceChannel
? { voiceAutoJoin: { channelId: voiceChannel.id, guildId: params.runtimeEnv.guildId } }
: {}),
statusReactionsToolOnly: scenarioRun.kind === "status-reactions-tool-only",
},
);
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: ["channels.discord", "messages", "plugins"],
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
await discordQaScenarioSupport.testing.waitForDiscordChannelRunning(
input.gateway as never,
params.accountId,
);
return {
discordScenarioContext: {
cfg,
configureScenario: async (implementation: DiscordQaScenarioImplementation) => {
const run = implementation.buildRun(params.runtimeEnv.sutApplicationId);
const voiceChannel =
run.kind === "voice-autojoin"
? await discordQaScenarioSupport.testing.resolveDiscordQaVoiceChannel({
guildId: params.runtimeEnv.guildId,
token: params.runtimeEnv.sutBotToken,
voiceChannelId: params.runtimeEnv.voiceChannelId,
})
: undefined;
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = discordQaScenarioSupport.testing.buildDiscordQaConfig(
snapshot.config as OpenClawConfig,
{
guildId: params.runtimeEnv.guildId,
channelId: params.runtimeEnv.channelId,
driverBotId: params.driverIdentity.id,
sutAccountId: params.accountId,
sutBotToken: params.runtimeEnv.sutBotToken,
},
{
...(voiceChannel
? {
voiceAutoJoin: {
channelId: voiceChannel.id,
guildId: params.runtimeEnv.guildId,
},
}
: {}),
statusReactionsToolOnly: run.kind === "status-reactions-tool-only",
},
);
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: ["channels.discord", "messages", "plugins"],
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
await discordQaScenarioSupport.testing.waitForDiscordChannelRunning(
input.gateway as never,
params.accountId,
);
return { cfg, run, ...(voiceChannel ? { voiceChannel } : {}) };
},
driverIdentity: params.driverIdentity,
observedMessages,
outputDir: input.outputDir,
runtimeEnv: params.runtimeEnv,
scenario: {
id: input.scenarioId,
timeoutMs: input.timeoutMs,
title: input.scenarioTitle,
},
sutAccountId: params.accountId,
sutIdentity: params.sutIdentity,
...(voiceChannel ? { voiceChannel } : {}),
} satisfies DiscordQaScenarioEnvironment,
};
};
@@ -1,13 +1,25 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { discordQaScenarioSupport } from "./discord-live.runtime.js";
import {
discordQaScenarioSupport,
type DiscordQaScenarioImplementation,
} from "./discord-live.runtime.js";
import type { DiscordQaScenarioEnvironment } from "./scenario-environment.js";
async function runDiscordScenario(environment: DiscordQaScenarioEnvironment, scenarioId: string) {
const scenario = discordQaScenarioSupport.testing.findScenario([scenarioId])[0];
if (!scenario) {
throw new Error(`unknown Discord QA scenario id: ${scenarioId}`);
}
const run = scenario.buildRun(environment.runtimeEnv.sutApplicationId);
export {
discordQaCanaryScenario,
discordQaMentionGatingScenario,
discordQaNativeHelpCommandRegistrationScenario,
discordQaStatusReactionsToolOnlyScenario,
discordQaThreadReplyFilepathAttachmentScenario,
discordQaVoiceAutojoinScenario,
} from "./discord-live.runtime.js";
export async function runDiscordScenario(
environment: DiscordQaScenarioEnvironment,
implementation: DiscordQaScenarioImplementation,
) {
const scenario = environment.scenario;
const { cfg, run, voiceChannel } = await environment.configureScenario(implementation);
if (run.kind === "application-command-registration") {
const registered =
await discordQaScenarioSupport.testing.assertDiscordApplicationCommandsRegistered({
@@ -19,13 +31,13 @@ async function runDiscordScenario(environment: DiscordQaScenarioEnvironment, sce
return { details: `native command registered (${registered.commandNames.join(", ")})` };
}
if (run.kind === "voice-autojoin") {
if (!environment.voiceChannel) {
if (!voiceChannel) {
throw new Error("Discord voice auto-join scenario did not resolve a voice channel.");
}
await discordQaScenarioSupport.testing.waitForDiscordVoiceState({
token: environment.runtimeEnv.sutBotToken,
guildId: environment.runtimeEnv.guildId,
channelId: environment.voiceChannel.id,
channelId: voiceChannel.id,
sutBotId: environment.sutIdentity.id,
timeoutMs: scenario.timeoutMs,
});
@@ -34,7 +46,7 @@ async function runDiscordScenario(environment: DiscordQaScenarioEnvironment, sce
if (run.kind === "thread-reply-filepath-attachment") {
const result =
await discordQaScenarioSupport.testing.runDiscordThreadReplyFilePathAttachmentScenario({
cfg: environment.cfg,
cfg,
driverBotId: environment.driverIdentity.id,
outputDir: environment.outputDir,
runtimeEnv: environment.runtimeEnv,
@@ -116,18 +128,3 @@ async function runDiscordScenario(environment: DiscordQaScenarioEnvironment, sce
throw error;
}
}
export const runDiscordCanaryScenario = (context: DiscordQaScenarioEnvironment) =>
runDiscordScenario(context, "discord-canary");
export const runDiscordMentionGatingScenario = (context: DiscordQaScenarioEnvironment) =>
runDiscordScenario(context, "discord-mention-gating");
export const runDiscordNativeHelpCommandRegistrationScenario = (
context: DiscordQaScenarioEnvironment,
) => runDiscordScenario(context, "discord-native-help-command-registration");
export const runDiscordVoiceAutojoinScenario = (context: DiscordQaScenarioEnvironment) =>
runDiscordScenario(context, "discord-voice-autojoin");
export const runDiscordStatusReactionsToolOnlyScenario = (context: DiscordQaScenarioEnvironment) =>
runDiscordScenario(context, "discord-status-reactions-tool-only");
export const runDiscordThreadReplyFilepathAttachmentScenario = (
context: DiscordQaScenarioEnvironment,
) => runDiscordScenario(context, "discord-thread-reply-filepath-attachment");
@@ -77,6 +77,8 @@ describe("matrix scenario environment", () => {
config: {},
gateway,
outputDir: "/tmp/matrix-qa/output",
scenarioId: "matrix-observer-reset",
scenarioTitle: "Matrix observer reset",
timeoutMs: 1_000,
waitForConfigRestartSettle: vi.fn(),
};
@@ -168,6 +170,8 @@ describe("matrix scenario environment", () => {
config: {},
gateway,
outputDir: "/tmp/matrix-qa/output",
scenarioId: "matrix-approval",
scenarioTitle: "Matrix approval",
timeoutMs: 1_000,
waitForConfigRestartSettle,
});
@@ -285,6 +289,8 @@ describe("matrix scenario environment", () => {
config: {},
gateway,
outputDir: "/tmp/matrix-qa/output",
scenarioId: "matrix-restart",
scenarioTitle: "Matrix restart",
timeoutMs: 1_000,
waitForConfigRestartSettle,
});
@@ -7,25 +7,36 @@ import * as whatsappScenarioRuntime from "./whatsapp/scenario-runtime.js";
const LANES = [
{
channel: "discord",
contextExpression: "discordScenarioContext",
modulePath: "./live-transports/discord/scenario-runtime.js",
runnerName: "runDiscordScenario",
runtime: discordScenarioRuntime,
},
{
channel: "slack",
contextExpression: "slackScenarioContext",
modulePath: "./live-transports/slack/scenario-runtime.js",
runnerName: "runSlackScenario",
runtime: slackScenarioRuntime,
},
{
channel: "whatsapp",
contextExpression: "whatsappScenarioContext",
modulePath: "./live-transports/whatsapp/scenario-runtime.js",
runnerName: "runWhatsAppScenario",
runtime: whatsappScenarioRuntime,
},
] as const;
function readScenarioModuleCallName(
type ScenarioModuleCall = {
args?: unknown[];
call: string;
};
function readScenarioModuleCall(
scenario: QaSeedScenarioWithSource,
modulePath: string,
): string | undefined {
): ScenarioModuleCall | undefined {
if (scenario.execution.kind !== "flow" || !scenario.execution.flow) {
return undefined;
}
@@ -48,7 +59,7 @@ function readScenarioModuleCallName(
}
const callPrefix = "scenarioModule.";
const callAction = actions.find(
(action): action is { call: string } =>
(action): action is ScenarioModuleCall =>
typeof action === "object" &&
action !== null &&
"call" in action &&
@@ -58,27 +69,42 @@ function readScenarioModuleCallName(
if (!callAction) {
throw new Error(`scenario module flow has no call: ${scenario.id}`);
}
return callAction.call.slice(callPrefix.length);
return { ...callAction, call: callAction.call.slice(callPrefix.length) };
}
describe("live transport scenario module parity", () => {
function readExpression(value: unknown): string | undefined {
return typeof value === "object" &&
value !== null &&
"expr" in value &&
typeof value.expr === "string"
? value.expr
: undefined;
}
describe("live transport scenario module routing", () => {
it.each(LANES)(
"keeps $channel scenario definitions and runtime exports in one-to-one parity",
({ channel, modulePath, runtime }) => {
"routes every $channel flow through one shared channel runner",
({ channel, contextExpression, modulePath, runnerName, runtime }) => {
expect(Reflect.get(runtime, runnerName)).toBeTypeOf("function");
const bindings = readQaScenarioPack().scenarios.flatMap((scenario) => {
if (scenario.execution.kind !== "flow" || scenario.execution.channel !== channel) {
return [];
}
const callName = readScenarioModuleCallName(scenario, modulePath);
return callName ? [{ callName, scenarioId: scenario.id }] : [];
});
const callNames = bindings.map(({ callName, scenarioId }) => {
expect(Reflect.get(runtime, callName), scenarioId).toBeTypeOf("function");
return callName;
const call = readScenarioModuleCall(scenario, modulePath);
return call ? [{ call, scenarioId: scenario.id }] : [];
});
expect(new Set(callNames).size).toBe(callNames.length);
expect(callNames.toSorted()).toEqual(Object.keys(runtime).toSorted());
for (const { call, scenarioId } of bindings) {
expect(call.call, scenarioId).toBe(runnerName);
expect(call.args, scenarioId).toHaveLength(2);
expect(readExpression(call.args?.[0]), scenarioId).toBe(contextExpression);
const implementationExpression = readExpression(call.args?.[1]);
const match = implementationExpression?.match(/^scenarioModule\["(\w+)"\]$/);
expect(match, scenarioId).toBeTruthy();
expect(Reflect.get(runtime, match?.[1] ?? ""), scenarioId).toBeTypeOf("object");
}
},
);
});
@@ -1,5 +1,7 @@
// QA Lab Slack tests cover module-specific flow preparation boundaries.
import { describe, expect, it, vi } from "vitest";
import { createSlackQaScenarioEnvironment } from "./scenario-environment.js";
import { slackQaAllowlistBlockScenario } from "./slack-live.scenario-implementations.js";
function createEnvironment() {
return createSlackQaScenarioEnvironment({
@@ -15,21 +17,26 @@ function createEnvironment() {
});
}
describe("Slack live scenario environment", () => {
it("leaves generic declarative flows to their own config preparation", async () => {
describe("Slack scenario environment", () => {
it("leaves generic declarative flows on the adapter's baseline config", async () => {
const gatewayCall = vi.fn();
const { prepareFlow } = createEnvironment();
await expect(
prepareFlow({
config: { policyKey: "dmPolicy", policyValue: "disabled" },
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/slack-output",
primaryModel: "mock-openai/gpt-5.6-luna",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
}),
).resolves.toBeUndefined();
const prepared = await prepareFlow({
config: { replyMarker: "QA-THREAD-FOLLOW-UP-OK" },
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/slack-output",
primaryModel: "mock-openai/gpt-5.6-luna",
scenarioId: "thread-follow-up",
scenarioTitle: "Thread follow-up",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
});
expect(prepared.slackScenarioContext.scenario).toEqual({
id: "thread-follow-up",
timeoutMs: 60_000,
title: "Thread follow-up",
});
expect(gatewayCall).not.toHaveBeenCalled();
});
@@ -59,16 +66,19 @@ describe("Slack live scenario environment", () => {
throw new Error(`unexpected gateway method: ${method}`);
});
const { prepareFlow } = createEnvironment();
await prepareFlow({
config: { slackScenarioId: "slack-allowlist-block" },
const prepared = await prepareFlow({
config: {},
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/slack-output",
primaryModel: "mock-openai/gpt-5.6-luna",
scenarioId: "slack-allowlist-block",
scenarioTitle: "Slack allowlist block",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
});
await prepared.slackScenarioContext.configureScenario(slackQaAllowlistBlockScenario);
const patchCall = gatewayCall.mock.calls.find(([method]) => method === "config.patch");
if (!patchCall) {
throw new Error("config.patch was not called");
@@ -10,25 +10,31 @@ import { buildSlackQaConfig } from "./slack-live.config.js";
import type {
SlackAuthIdentity,
SlackObservedMessage,
SlackQaScenarioImplementation,
SlackQaScenarioContext,
SlackQaScenarioMetadata,
SlackQaScenarioRun,
} from "./slack-live.contracts.js";
import { assertSlackCodexApprovalModelSupported } from "./slack-live.contracts.js";
import { waitForSlackChannelStable } from "./slack-live.message-observations.js";
import { sendSlackChannelMessage } from "./slack-live.observations.js";
import { getSlackQaScenarioDefinition } from "./slack-live.scenarios.js";
type AdapterFactory = NonNullable<QaRunnerCliRegistration["adapterFactory"]>;
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>>;
type FlowPreparationInput = Parameters<NonNullable<AdapterDefinition["prepareFlow"]>>[0];
export type SlackQaScenarioEnvironment = {
cfg: OpenClawConfig;
channelId: string;
configureScenario: (implementation: SlackQaScenarioImplementation) => Promise<{
cfg: OpenClawConfig;
primaryModel: string;
run: SlackQaScenarioRun;
}>;
context: Omit<SlackQaScenarioContext, "sentTs">;
gatewayDebugDirPath: string;
observedMessages: SlackObservedMessage[];
outputDir: string;
primaryModel: string;
scenario: SlackQaScenarioMetadata;
stopGateway: (preserveDebugArtifacts: boolean) => Promise<void>;
sutAccountId: string;
sutIdentity: SlackAuthIdentity;
@@ -62,41 +68,6 @@ export function createSlackQaScenarioEnvironment(params: {
const observedMessages: SlackObservedMessage[] = [];
const prepareFlow = async (input: FlowPreparationInput) => {
const scenarioId = input.config.slackScenarioId;
if (typeof scenarioId !== "string") {
return undefined;
}
if (!input.primaryModel) {
throw new Error("Slack QA module flow requires a primary model");
}
const primaryModel = input.primaryModel;
const scenario = getSlackQaScenarioDefinition(scenarioId);
const scenarioRun = scenario.buildRun(params.sutIdentity.userId);
if (scenarioRun.kind === "codex-approval") {
assertSlackCodexApprovalModelSupported(primaryModel);
}
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = buildSlackQaConfig(snapshot.config as OpenClawConfig, {
channelId: params.channelId,
driverBotUserId: params.driverBotUserId,
overrides: scenario.configOverrides,
primaryModel,
sutAccountId: params.accountId,
sutAppToken: params.sutAppToken,
sutBotToken: params.sutBotToken,
});
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: resolveSlackQaReplacePaths(params.accountId, params.channelId),
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
const readinessMode =
scenarioRun.kind === "approval" || scenarioRun.kind === "codex-approval"
? "started"
: "connected";
await waitForSlackChannelStable(input.gateway as never, params.accountId, readinessMode);
const context = {
channelId: params.channelId,
driverClient: params.driverClient,
@@ -115,13 +86,47 @@ export function createSlackQaScenarioEnvironment(params: {
} satisfies Omit<SlackQaScenarioContext, "sentTs">;
return {
slackScenarioContext: {
cfg,
channelId: params.channelId,
configureScenario: async (implementation: SlackQaScenarioImplementation) => {
if (!input.primaryModel) {
throw new Error("Slack QA module flow requires a primary model");
}
const primaryModel = input.primaryModel;
const run = implementation.buildRun(params.sutIdentity.userId);
if (run.kind === "codex-approval") {
assertSlackCodexApprovalModelSupported(primaryModel);
}
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = buildSlackQaConfig(snapshot.config as OpenClawConfig, {
channelId: params.channelId,
driverBotUserId: params.driverBotUserId,
overrides: implementation.configOverrides,
primaryModel,
sutAccountId: params.accountId,
sutAppToken: params.sutAppToken,
sutBotToken: params.sutBotToken,
});
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: resolveSlackQaReplacePaths(params.accountId, params.channelId),
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
const readinessMode =
run.kind === "approval" || run.kind === "codex-approval" ? "started" : "connected";
await waitForSlackChannelStable(input.gateway as never, params.accountId, readinessMode);
return { cfg, primaryModel, run };
},
context,
gatewayDebugDirPath: path.join(input.outputDir, "gateway-debug"),
observedMessages,
outputDir: input.outputDir,
primaryModel,
scenario: {
id: input.scenarioId,
timeoutMs: input.timeoutMs,
title: input.scenarioTitle,
},
stopGateway: async (preserveDebugArtifacts: boolean) => {
if (!input.gateway.stop) {
throw new Error("Slack QA scenario requires gateway stop support");
@@ -1,7 +1,10 @@
import type { SlackQaScenarioEnvironment } from "./scenario-environment.js";
import { runSlackApprovalScenario } from "./slack-live.approvals.js";
import { runSlackCodexApprovalScenario } from "./slack-live.codex-approval-runner.js";
import type { SlackQaMessageScenarioRun } from "./slack-live.contracts.js";
import type {
SlackQaMessageScenarioRun,
SlackQaScenarioImplementation,
} from "./slack-live.contracts.js";
import {
observeSlackScenarioMessages,
waitForSlackNoReply,
@@ -12,7 +15,27 @@ import {
collectSlackBlockText,
sendSlackChannelMessage,
} from "./slack-live.observations.js";
import { getSlackQaScenarioDefinition } from "./slack-live.scenarios.js";
export {
slackQaAllowlistBlockScenario,
slackQaApprovalExecNativeScenario,
slackQaApprovalPluginNativeScenario,
slackQaCanaryScenario,
slackQaChannelDisabledWarningScenario,
slackQaChartPresentationNativeScenario,
slackQaCodexApprovalExecNativeScenario,
slackQaCodexApprovalPluginNativeScenario,
slackQaMentionGatingScenario,
slackQaMpimAppMentionDedupeScenario,
slackQaProgressCommentaryFalseScenario,
slackQaProgressCommentaryOmittedScenario,
slackQaProgressCommentaryTrueScenario,
slackQaProgressCommentaryVerboseDedupeScenario,
slackQaReactionGlyphNativeScenario,
slackQaTableInvalidBlocksFallbackScenario,
slackQaTablePresentationNativeScenario,
slackQaTopLevelReplyShapeScenario,
} from "./slack-live.scenario-implementations.js";
async function runSlackMessageScenario(params: {
environment: SlackQaScenarioEnvironment;
@@ -108,12 +131,15 @@ async function runSlackMessageScenario(params: {
}
}
async function runSlackScenario(environment: SlackQaScenarioEnvironment, scenarioId: string) {
const scenario = getSlackQaScenarioDefinition(scenarioId);
const run = scenario.buildRun(environment.sutIdentity.userId);
export async function runSlackScenario(
environment: SlackQaScenarioEnvironment,
implementation: SlackQaScenarioImplementation,
) {
const scenario = environment.scenario;
const { cfg, primaryModel, run } = await environment.configureScenario(implementation);
if (run.kind === "direct-transport") {
const result = await run.execute({
cfg: environment.cfg,
cfg,
channelId: environment.channelId,
sutAccountId: environment.sutAccountId,
sutIdentity: environment.sutIdentity,
@@ -159,7 +185,7 @@ async function runSlackScenario(environment: SlackQaScenarioEnvironment, scenari
channelId: environment.channelId,
context: environment.context,
observedMessages: environment.observedMessages,
primaryModel: environment.primaryModel,
primaryModel,
run,
scenario,
stopGateway: environment.stopGateway,
@@ -178,41 +204,3 @@ async function runSlackScenario(environment: SlackQaScenarioEnvironment, scenari
timeoutMs: scenario.timeoutMs,
});
}
export const runSlackCanaryScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-canary");
export const runSlackMentionGatingScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-mention-gating");
export const runSlackMpimAppMentionDedupeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-mpim-app-mention-dedupe");
export const runSlackAllowlistBlockScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-allowlist-block");
export const runSlackChannelDisabledWarningScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-channel-disabled-warning");
export const runSlackTopLevelReplyShapeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-top-level-reply-shape");
export const runSlackProgressCommentaryTrueScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-progress-commentary-true");
export const runSlackProgressCommentaryFalseScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-progress-commentary-false");
export const runSlackProgressCommentaryOmittedScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-progress-commentary-omitted");
export const runSlackProgressCommentaryVerboseDedupeScenario = (
context: SlackQaScenarioEnvironment,
) => runSlackScenario(context, "slack-progress-commentary-verbose-dedupe");
export const runSlackChartPresentationNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-chart-presentation-native");
export const runSlackTablePresentationNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-table-presentation-native");
export const runSlackTableInvalidBlocksFallbackScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-table-invalid-blocks-fallback");
export const runSlackReactionGlyphNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-reaction-glyph-native");
export const runSlackApprovalExecNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-approval-exec-native");
export const runSlackApprovalPluginNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-approval-plugin-native");
export const runSlackCodexApprovalExecNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-codex-approval-exec-native");
export const runSlackCodexApprovalPluginNativeScenario = (context: SlackQaScenarioEnvironment) =>
runSlackScenario(context, "slack-codex-approval-plugin-native");
@@ -9,7 +9,6 @@ import {
import {
SLACK_QA_APPROVAL_DECISION_TIMEOUT_MS,
SLACK_QA_APPROVAL_CHECKPOINT_DEFAULT_TIMEOUT_MS,
type SlackQaScenarioId,
type SlackQaApprovalKind,
type SlackQaApprovalDecision,
type SlackQaApprovalScenarioRun,
@@ -79,7 +78,7 @@ export async function writeSlackApprovalCheckpoint(params: {
decision?: SlackQaApprovalDecision;
message: SlackMessage;
observedAt: string;
scenarioId: SlackQaScenarioId;
scenarioId: string;
state: SlackApprovalCheckpointState;
}) {
const config = resolveSlackApprovalCheckpointConfig();
@@ -13,7 +13,7 @@ import {
type SlackQaApprovalDecision,
type SlackQaApprovalScenarioRun,
type SlackQaScenarioContext,
type SlackQaScenarioDefinition,
type SlackQaScenarioMetadata,
type SlackAuthIdentity,
type SlackObservedMessage,
type SlackApprovalArtifact,
@@ -291,7 +291,7 @@ export async function runSlackApprovalScenario(params: {
context: Omit<SlackQaScenarioContext, "sentTs">;
observedMessages: SlackObservedMessage[];
run: SlackQaApprovalScenarioRun;
scenario: SlackQaScenarioDefinition;
scenario: SlackQaScenarioMetadata;
sutAccountId: string;
}) {
const requestStartedAt = new Date();
@@ -20,7 +20,7 @@ import {
import type {
SlackQaCodexApprovalScenarioRun,
SlackQaScenarioContext,
SlackQaScenarioDefinition,
SlackQaScenarioMetadata,
SlackObservedMessage,
SlackApprovalArtifact,
} from "./slack-live.contracts.js";
@@ -31,7 +31,7 @@ export async function runSlackCodexApprovalScenario(params: {
observedMessages: SlackObservedMessage[];
primaryModel: string;
run: SlackQaCodexApprovalScenarioRun;
scenario: SlackQaScenarioDefinition;
scenario: SlackQaScenarioMetadata;
stopGateway: (preserveDebugArtifacts: boolean) => Promise<void>;
sutAccountId: string;
}) {
@@ -97,7 +97,7 @@ async function runSlackCodexApprovalScenarioInner(params: {
observedMessages: SlackObservedMessage[];
primaryModel: string;
run: SlackQaCodexApprovalScenarioRun;
scenario: SlackQaScenarioDefinition;
scenario: SlackQaScenarioMetadata;
sutAccountId: string;
}) {
const requestStartedAt = new Date();
@@ -12,7 +12,7 @@ import {
type SlackQaCodexApprovalMethod,
type SlackQaCodexApprovalScenarioRun,
type SlackQaScenarioContext,
type SlackQaScenarioDefinition,
type SlackQaScenarioMetadata,
} from "./slack-live.contracts.js";
export function resolveCodexFileApprovalTargetPath(token: string) {
@@ -224,7 +224,7 @@ export async function startCodexApprovalAgentRun(params: {
primaryModel: string;
run: SlackQaCodexApprovalScenarioRun;
runId: string;
scenario: SlackQaScenarioDefinition;
scenario: SlackQaScenarioMetadata;
sessionKey: string;
sutAccountId: string;
}) {
@@ -258,7 +258,7 @@ export async function startCodexApprovalAgentRun(params: {
}
export function buildCodexApprovalSessionKey(params: {
scenario: SlackQaScenarioDefinition;
scenario: SlackQaScenarioMetadata;
token: string;
}) {
return `agent:qa:${params.scenario.id}-${params.token.toLowerCase()}`;
@@ -4,7 +4,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { z } from "zod";
import type { startQaGatewayChild } from "../../gateway-child.js";
import { splitQaModelRef } from "../../model-selection.js";
import type { RuntimeId } from "../../runtime-parity.js";
export type SlackQaRuntimeEnv = {
channelId: string;
@@ -85,26 +84,6 @@ export const SLACK_QA_NATIVE_TABLE = {
// These scenarios force the Codex harness, whose default provider set is intentionally narrow.
const SLACK_QA_CODEX_PROVIDER_IDS = new Set(["codex", "openai"]);
export type SlackQaScenarioId =
| "slack-allowlist-block"
| "slack-approval-exec-native"
| "slack-approval-plugin-native"
| "slack-canary"
| "slack-codex-approval-exec-native"
| "slack-codex-approval-plugin-native"
| "slack-chart-presentation-native"
| "slack-channel-disabled-warning"
| "slack-mention-gating"
| "slack-mpim-app-mention-dedupe"
| "slack-progress-commentary-false"
| "slack-progress-commentary-omitted"
| "slack-progress-commentary-true"
| "slack-progress-commentary-verbose-dedupe"
| "slack-reaction-glyph-native"
| "slack-table-invalid-blocks-fallback"
| "slack-table-presentation-native"
| "slack-top-level-reply-shape";
export type SlackQaApprovalKind = "exec" | "plugin";
export type SlackQaApprovalDecision = "allow-always" | "allow-once" | "deny";
export const SLACK_QA_APPROVAL_ACTION_PREFIX = "openclaw:approval:v1:";
@@ -184,7 +163,7 @@ export type SlackQaCodexApprovalScenarioRun = {
token: string;
};
type SlackQaScenarioRun =
export type SlackQaScenarioRun =
| SlackQaApprovalScenarioRun
| SlackQaCodexApprovalScenarioRun
| SlackQaDirectTransportScenarioRun
@@ -230,13 +209,15 @@ export type SlackQaScenarioContext = {
waitForReady: () => Promise<void>;
};
export type SlackQaScenarioDefinition = {
id: SlackQaScenarioId;
title: string;
timeoutMs: number;
export type SlackQaScenarioImplementation = {
buildRun: (sutUserId: string) => SlackQaScenarioRun;
configOverrides?: SlackQaConfigOverrides;
forcedRuntime?: RuntimeId;
};
export type SlackQaScenarioMetadata = {
id: string;
timeoutMs: number;
title: string;
};
export type SlackAuthIdentity = {
@@ -1,5 +1,7 @@
// Qa Lab tests cover slack live plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readQaScenarioById } from "../../scenario-catalog.js";
import { requireFlowScenario } from "../../scenario-catalog.test-utils.js";
import { testing as adapterTesting } from "./adapter.runtime.js";
import { resolveSlackQaScenarioIds } from "./scenario-selection.js";
import { resolveApprovalDecision } from "./slack-live.approvals.js";
@@ -13,7 +15,10 @@ import {
parseSlackQaCredentialPayload,
resolveSlackQaRuntimeEnv,
} from "./slack-live.config.js";
import { assertSlackCodexApprovalModelSupported } from "./slack-live.contracts.js";
import {
assertSlackCodexApprovalModelSupported,
type SlackQaScenarioImplementation,
} from "./slack-live.contracts.js";
import { buildSlackInvalidBlocksTableProbe } from "./slack-live.invalid-blocks.js";
import {
observeSlackScenarioMessages,
@@ -25,16 +30,32 @@ import {
extractSlackNativeApprovalId,
runSlackTableInvalidBlocksFallbackScenario,
} from "./slack-live.observations.js";
import {
getSlackQaScenarioDefinition,
listSlackQaScenarioCatalog,
} from "./slack-live.scenarios.js";
import * as slackScenarioImplementations from "./slack-live.scenario-implementations.js";
function toSlackScenarioExportName(id: string): string {
const suffix = id
.replace(/^slack-/, "")
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join("");
return `slackQa${suffix}Scenario`;
}
function findScenario(ids?: string[]) {
const requestedIds = new Set(ids?.length ? ids : resolveSlackQaScenarioIds({}));
return listSlackQaScenarioCatalog()
.filter(({ id }) => requestedIds.has(id))
.map(({ id }) => getSlackQaScenarioDefinition(id));
return resolveSlackQaScenarioIds({ scenarioIds: ids }).map((id) => {
const implementation = (
slackScenarioImplementations as unknown as Record<string, SlackQaScenarioImplementation>
)[toSlackScenarioExportName(id)];
if (!implementation) {
throw new Error(`missing Slack test implementation for ${id}`);
}
const scenario = requireFlowScenario(readQaScenarioById(id));
return Object.assign({}, implementation, {
id,
timeoutMs: scenario.execution.timeoutMs ?? 60_000,
title: scenario.title,
});
});
}
const testing = {
@@ -207,43 +228,29 @@ describe("Slack live QA runtime helpers", () => {
});
it("selects native scenarios by explicit id", () => {
expect(
testing
.findScenario([
"slack-chart-presentation-native",
"slack-table-presentation-native",
"slack-table-invalid-blocks-fallback",
"slack-progress-commentary-true",
"slack-progress-commentary-false",
"slack-progress-commentary-omitted",
"slack-progress-commentary-verbose-dedupe",
"slack-reaction-glyph-native",
"slack-approval-exec-native",
"slack-approval-plugin-native",
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
"slack-channel-disabled-warning",
])
.map((scenario) => scenario.id),
).toEqual([
"slack-channel-disabled-warning",
const scenarioIds = [
"slack-chart-presentation-native",
"slack-table-presentation-native",
"slack-table-invalid-blocks-fallback",
"slack-progress-commentary-true",
"slack-progress-commentary-false",
"slack-progress-commentary-omitted",
"slack-progress-commentary-verbose-dedupe",
"slack-chart-presentation-native",
"slack-table-presentation-native",
"slack-table-invalid-blocks-fallback",
"slack-reaction-glyph-native",
"slack-approval-exec-native",
"slack-approval-plugin-native",
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
]);
expect(testing.findScenario(["slack-codex-approval-exec-native"])[0]?.forcedRuntime).toBe(
"codex",
);
expect(testing.findScenario(["slack-canary"])[0]?.forcedRuntime).toBeUndefined();
"slack-channel-disabled-warning",
];
const selectedIds = testing.findScenario(scenarioIds).map((scenario) => scenario.id);
expect(new Set(selectedIds)).toEqual(new Set(scenarioIds));
expect(
requireFlowScenario(readQaScenarioById("slack-codex-approval-exec-native")).execution.runtime,
).toBe("codex");
expect(
requireFlowScenario(readQaScenarioById("slack-canary")).execution.runtime,
).toBeUndefined();
});
it("accepts only Codex harness providers for Codex approval scenarios", () => {
@@ -0,0 +1,417 @@
// QA Lab Slack live scenario implementations.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { waitForSlackReaction } from "./slack-live.codex-approval.js";
import {
SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
SLACK_QA_LOG_TAIL_TIMEOUT_MS,
type SlackQaScenarioImplementation,
type SlackQaScenarioContext,
} from "./slack-live.contracts.js";
import {
isExpectedSlackNativeChartMessage,
isExpectedSlackNativeTableMessage,
runSlackTableInvalidBlocksFallbackScenario,
waitForSlackStoredMessage,
} from "./slack-live.observations.js";
import {
buildSlackChartMessageToolArgs,
renderSlackChartAccessibleText,
buildSlackTableMessageToolArgs,
renderSlackTableAccessibleText,
buildSlackProgressCommentaryRun,
} from "./slack-live.scenario-fixtures.js";
export const slackQaCanaryScenario: SlackQaScenarioImplementation = {
buildRun: (sutUserId) => {
const token = `SLACK_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
};
},
};
export const slackQaMentionGatingScenario: SlackQaScenarioImplementation = {
buildRun: () => {
const token = `SLACK_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: false,
input: `reply with only this exact marker: ${token}`,
matchText: token,
};
},
};
export const slackQaMpimAppMentionDedupeScenario: SlackQaScenarioImplementation = {
configOverrides: { groupDmEnabled: true },
buildRun: (sutUserId) => {
const token = `SLACK_QA_MPIM_${randomUUID().slice(0, 8).toUpperCase()}`;
let openedChannelId: string | undefined;
const closeOpenedChannel = async (context: Omit<SlackQaScenarioContext, "sentTs">) => {
if (!openedChannelId) {
return;
}
const channelId = openedChannelId;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
await context.sutReadClient.conversations.close({ channel: channelId });
openedChannelId = undefined;
return;
} catch (error) {
if (attempt === 2) {
throw error;
}
// Retain ownership until Slack confirms closure; one bounded retry
// covers a transient API failure without hiding a leaked MPIM.
await sleep(500);
}
}
};
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
settleObservedMs: 60_000,
beforeRun: async (context) => {
const driverAuth = await context.driverClient.auth.test();
const driverUserId = driverAuth.user_id?.trim();
if (!driverUserId) {
throw new Error("Slack QA driver auth.test returned no user_id");
}
const members = await context.sutReadClient.conversations.members({
channel: context.channelId,
limit: 100,
});
const candidateUserIds = (members.members ?? []).filter(
(userId) => userId !== driverUserId && userId !== context.sutIdentity.userId,
);
for (const userId of candidateUserIds) {
const user = (await context.sutReadClient.users.info({ user: userId })).user;
if (!user || user.deleted || user.is_bot) {
continue;
}
const opened = await context.sutReadClient.conversations.open({
return_im: true,
users: `${driverUserId},${userId}`,
});
const channelId = opened.channel?.id?.trim();
if (!channelId) {
continue;
}
// Track ownership before the metadata call so outer cleanup can still
// close the MPIM when Slack rejects or times out during inspection.
openedChannelId = channelId;
const info = await context.sutReadClient.conversations.info({ channel: channelId });
if (info.channel?.is_mpim && channelId.startsWith("C")) {
return { details: "opened C-prefixed MPIM", inputChannelId: channelId };
}
await closeOpenedChannel(context);
}
throw new Error("Slack QA channel has no human member yielding a C-prefixed MPIM");
},
verifyObserved: ({ messages }) => {
const uniqueReplies = new Map(messages.map((message) => [message.ts, message]));
const matchingReplies = [...uniqueReplies.values()].filter((message) =>
message.text.includes(token),
);
if (uniqueReplies.size !== 1 || matchingReplies.length !== 1) {
throw new Error(
`expected one MPIM response with the marker, got ${uniqueReplies.size} response(s) and ${matchingReplies.length} marker match(es)`,
);
}
return "one MPIM reply observed after message/app_mention twin delivery";
},
cleanup: closeOpenedChannel,
};
},
};
export const slackQaAllowlistBlockScenario: SlackQaScenarioImplementation = {
configOverrides: {
allowFrom: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
users: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
},
buildRun: (sutUserId) => {
const token = `SLACK_QA_BLOCK_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: false,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
};
},
};
export const slackQaChannelDisabledWarningScenario: SlackQaScenarioImplementation = {
configOverrides: { channelEnabled: false },
buildRun: (sutUserId) => {
const marker = `SLACK_QA_DISABLED_${randomUUID().slice(0, 8).toUpperCase()}`;
let logCursor = 0;
return {
expectReply: false,
input: `<@${sutUserId}> reply with only this exact marker: ${marker}`,
matchText: marker,
preserveGatewayDebug: true,
beforeRun: async ({ gateway }) => {
const gatewayLogTail = (await gateway.call(
"logs.tail",
{ limit: 1, maxBytes: 32_000 },
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
)) as { cursor?: unknown };
logCursor = typeof gatewayLogTail.cursor === "number" ? gatewayLogTail.cursor : 0;
},
afterNoReply: async ({ gateway }) => {
const gatewayLogTail = (await gateway.call(
"logs.tail",
{ cursor: logCursor, limit: 200, maxBytes: 256_000 },
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
)) as { lines?: unknown };
const gatewayLogLines = Array.isArray(gatewayLogTail.lines)
? gatewayLogTail.lines.filter((line): line is string => typeof line === "string")
: [];
const expectedFields = [
"Slack channel denied by configuration",
"channel_not_allowed",
"channel_disabled",
];
if (
!gatewayLogLines.some((line) => expectedFields.every((field) => line.includes(field)))
) {
throw new Error("disabled Slack channel did not emit the structured warning");
}
return "structured disabled-channel warning observed";
},
};
},
};
export const slackQaTopLevelReplyShapeScenario: SlackQaScenarioImplementation = {
configOverrides: { replyToMode: "off" },
buildRun: (sutUserId) => {
const token = `SLACK_QA_TOPLEVEL_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
verify: (message) => {
if (message.thread_ts) {
throw new Error(
`expected top-level Slack reply without thread_ts; got ${message.thread_ts}`,
);
}
},
};
},
};
export const slackQaProgressCommentaryTrueScenario: SlackQaScenarioImplementation = {
configOverrides: {
progress: { commentary: true, toolProgress: false },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "draft",
toolProgress: "absent",
}),
};
export const slackQaProgressCommentaryFalseScenario: SlackQaScenarioImplementation = {
configOverrides: {
progress: { commentary: false, toolProgress: false },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "absent",
toolProgress: "absent",
}),
};
export const slackQaProgressCommentaryOmittedScenario: SlackQaScenarioImplementation = {
configOverrides: {
progress: { toolProgress: true },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "draft",
toolProgress: "draft",
}),
};
export const slackQaProgressCommentaryVerboseDedupeScenario: SlackQaScenarioImplementation = {
configOverrides: {
progress: { commentary: true, toolProgress: false, verboseDefault: "on" },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "standalone",
toolProgress: "standalone",
}),
};
export const slackQaChartPresentationNativeScenario: SlackQaScenarioImplementation = {
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const summaryText = `SLACK_QA_CHART_SUMMARY_${suffix}`;
const finalMarker = `SLACK_QA_CHART_DONE_${suffix}`;
const messageToolArgs = buildSlackChartMessageToolArgs(summaryText);
return {
expectReply: true,
input: [
`<@${sutUserId}> Slack native chart QA check ${summaryText}.`,
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
`After the chart send succeeds, reply with only this exact marker: ${finalMarker}`,
].join(" "),
matchText: finalMarker,
afterReply: async (_message, context) => {
await waitForSlackStoredMessage({
channelId: context.channelId,
client: context.sutReadClient,
description: "message with native chart",
matchesMessage: (message) =>
isExpectedSlackNativeChartMessage(message, renderSlackChartAccessibleText(summaryText)),
oldestTs: context.sentTs,
sutIdentity: context.sutIdentity,
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
});
return "verified native data_visualization block and deterministic accessible text";
},
};
},
};
export const slackQaTablePresentationNativeScenario: SlackQaScenarioImplementation = {
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const summaryText = `SLACK_QA_TABLE_SUMMARY_${suffix}`;
const finalMarker = `SLACK_QA_TABLE_DONE_${suffix}`;
const messageToolArgs = buildSlackTableMessageToolArgs(summaryText);
return {
expectReply: true,
input: [
`<@${sutUserId}> Slack native table QA check ${summaryText}.`,
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
`After the table send succeeds, reply with only this exact marker: ${finalMarker}`,
].join(" "),
matchText: finalMarker,
afterReply: async (_message, context) => {
await waitForSlackStoredMessage({
channelId: context.channelId,
client: context.sutReadClient,
description: "message with native table",
matchesMessage: (message) =>
isExpectedSlackNativeTableMessage(message, renderSlackTableAccessibleText(summaryText)),
oldestTs: context.sentTs,
sutIdentity: context.sutIdentity,
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
});
return "verified native data_table block and deterministic accessible text";
},
};
},
};
export const slackQaTableInvalidBlocksFallbackScenario: SlackQaScenarioImplementation = {
buildRun: () => ({
kind: "direct-transport",
execute: runSlackTableInvalidBlocksFallbackScenario,
}),
};
export const slackQaReactionGlyphNativeScenario: SlackQaScenarioImplementation = {
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const token = `SLACK_QA_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: [
`<@${sutUserId}> use the message tool exactly once to react to this message.`,
'Set action to "react", channel to "slack", and emoji to exactly "✅".',
"Do not substitute a shortcode.",
`After the reaction succeeds, reply with only this exact marker: ${token}`,
].join(" "),
matchText: token,
afterReply: async (_message, context) => {
await waitForSlackReaction({
channelId: context.channelId,
client: context.sutReadClient,
expectedReactionName: "white_check_mark",
messageId: context.sentTs,
sutUserId: context.sutIdentity.userId,
timeoutMs: SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
});
return "verified SUT white_check_mark reaction from exact glyph instruction";
},
};
},
};
export const slackQaApprovalExecNativeScenario: SlackQaScenarioImplementation = {
configOverrides: {
approvals: {
exec: true,
target: "channel",
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
kind: "approval",
token: `SLACK_QA_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const slackQaApprovalPluginNativeScenario: SlackQaScenarioImplementation = {
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
},
buildRun: () => ({
approvalKind: "plugin",
decision: "allow-once",
kind: "approval",
token: `SLACK_QA_PLUGIN_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const slackQaCodexApprovalExecNativeScenario: SlackQaScenarioImplementation = {
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
codexApproval: true,
},
buildRun: () => ({
approvalKind: "plugin",
appServerMethod: "item/commandExecution/requestApproval",
decision: "allow-once",
kind: "codex-approval",
token: `SLACK_QA_CODEX_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const slackQaCodexApprovalPluginNativeScenario: SlackQaScenarioImplementation = {
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
codexApproval: true,
},
buildRun: () => ({
approvalKind: "plugin",
appServerMethod: "item/fileChange/requestApproval",
decision: "allow-once",
kind: "codex-approval",
token: `SLACK_QA_CODEX_FILE_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
@@ -1,476 +0,0 @@
// QA Lab Slack live scenario catalog.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { waitForSlackReaction } from "./slack-live.codex-approval.js";
import {
SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
SLACK_QA_LOG_TAIL_TIMEOUT_MS,
type SlackQaScenarioDefinition,
type SlackQaScenarioContext,
} from "./slack-live.contracts.js";
import {
isExpectedSlackNativeChartMessage,
isExpectedSlackNativeTableMessage,
runSlackTableInvalidBlocksFallbackScenario,
waitForSlackStoredMessage,
} from "./slack-live.observations.js";
import {
buildSlackChartMessageToolArgs,
renderSlackChartAccessibleText,
buildSlackTableMessageToolArgs,
renderSlackTableAccessibleText,
buildSlackProgressCommentaryRun,
} from "./slack-live.scenario-fixtures.js";
const SLACK_QA_SCENARIOS: SlackQaScenarioDefinition[] = [
{
id: "slack-canary",
title: "Slack canary echo",
timeoutMs: 45_000,
buildRun: (sutUserId) => {
const token = `SLACK_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
};
},
},
{
id: "slack-mention-gating",
title: "Slack unmentioned bot message does not trigger",
timeoutMs: 8_000,
buildRun: () => {
const token = `SLACK_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: false,
input: `reply with only this exact marker: ${token}`,
matchText: token,
};
},
},
{
id: "slack-mpim-app-mention-dedupe",
title: "Slack MPIM app mention dispatches once",
timeoutMs: 90_000,
configOverrides: { groupDmEnabled: true },
buildRun: (sutUserId) => {
const token = `SLACK_QA_MPIM_${randomUUID().slice(0, 8).toUpperCase()}`;
let openedChannelId: string | undefined;
const closeOpenedChannel = async (context: Omit<SlackQaScenarioContext, "sentTs">) => {
if (!openedChannelId) {
return;
}
const channelId = openedChannelId;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
await context.sutReadClient.conversations.close({ channel: channelId });
openedChannelId = undefined;
return;
} catch (error) {
if (attempt === 2) {
throw error;
}
// Retain ownership until Slack confirms closure; one bounded retry
// covers a transient API failure without hiding a leaked MPIM.
await sleep(500);
}
}
};
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
settleObservedMs: 60_000,
beforeRun: async (context) => {
const driverAuth = await context.driverClient.auth.test();
const driverUserId = driverAuth.user_id?.trim();
if (!driverUserId) {
throw new Error("Slack QA driver auth.test returned no user_id");
}
const members = await context.sutReadClient.conversations.members({
channel: context.channelId,
limit: 100,
});
const candidateUserIds = (members.members ?? []).filter(
(userId) => userId !== driverUserId && userId !== context.sutIdentity.userId,
);
for (const userId of candidateUserIds) {
const user = (await context.sutReadClient.users.info({ user: userId })).user;
if (!user || user.deleted || user.is_bot) {
continue;
}
const opened = await context.sutReadClient.conversations.open({
return_im: true,
users: `${driverUserId},${userId}`,
});
const channelId = opened.channel?.id?.trim();
if (!channelId) {
continue;
}
// Track ownership before the metadata call so outer cleanup can still
// close the MPIM when Slack rejects or times out during inspection.
openedChannelId = channelId;
const info = await context.sutReadClient.conversations.info({ channel: channelId });
if (info.channel?.is_mpim && channelId.startsWith("C")) {
return { details: "opened C-prefixed MPIM", inputChannelId: channelId };
}
await closeOpenedChannel(context);
}
throw new Error("Slack QA channel has no human member yielding a C-prefixed MPIM");
},
verifyObserved: ({ messages }) => {
const uniqueReplies = new Map(messages.map((message) => [message.ts, message]));
const matchingReplies = [...uniqueReplies.values()].filter((message) =>
message.text.includes(token),
);
if (uniqueReplies.size !== 1 || matchingReplies.length !== 1) {
throw new Error(
`expected one MPIM response with the marker, got ${uniqueReplies.size} response(s) and ${matchingReplies.length} marker match(es)`,
);
}
return "one MPIM reply observed after message/app_mention twin delivery";
},
cleanup: closeOpenedChannel,
};
},
},
{
id: "slack-allowlist-block",
title: "Slack non-allowlisted sender does not trigger",
timeoutMs: 8_000,
configOverrides: {
allowFrom: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
users: ["U_OPENCLAW_QA_NEVER_ALLOWED"],
},
buildRun: (sutUserId) => {
const token = `SLACK_QA_BLOCK_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: false,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
};
},
},
{
id: "slack-channel-disabled-warning",
title: "Slack disabled channel warns and does not trigger",
timeoutMs: 8_000,
configOverrides: { channelEnabled: false },
buildRun: (sutUserId) => {
const marker = `SLACK_QA_DISABLED_${randomUUID().slice(0, 8).toUpperCase()}`;
let logCursor = 0;
return {
expectReply: false,
input: `<@${sutUserId}> reply with only this exact marker: ${marker}`,
matchText: marker,
preserveGatewayDebug: true,
beforeRun: async ({ gateway }) => {
const gatewayLogTail = (await gateway.call(
"logs.tail",
{ limit: 1, maxBytes: 32_000 },
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
)) as { cursor?: unknown };
logCursor = typeof gatewayLogTail.cursor === "number" ? gatewayLogTail.cursor : 0;
},
afterNoReply: async ({ gateway }) => {
const gatewayLogTail = (await gateway.call(
"logs.tail",
{ cursor: logCursor, limit: 200, maxBytes: 256_000 },
{ timeoutMs: SLACK_QA_LOG_TAIL_TIMEOUT_MS },
)) as { lines?: unknown };
const gatewayLogLines = Array.isArray(gatewayLogTail.lines)
? gatewayLogTail.lines.filter((line): line is string => typeof line === "string")
: [];
const expectedFields = [
"Slack channel denied by configuration",
"channel_not_allowed",
"channel_disabled",
];
if (
!gatewayLogLines.some((line) => expectedFields.every((field) => line.includes(field)))
) {
throw new Error("disabled Slack channel did not emit the structured warning");
}
return "structured disabled-channel warning observed";
},
};
},
},
{
id: "slack-top-level-reply-shape",
title: "Slack top-level reply stays top-level",
timeoutMs: 45_000,
configOverrides: { replyToMode: "off" },
buildRun: (sutUserId) => {
const token = `SLACK_QA_TOPLEVEL_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: `<@${sutUserId}> reply with only this exact marker: ${token}`,
matchText: token,
verify: (message) => {
if (message.thread_ts) {
throw new Error(
`expected top-level Slack reply without thread_ts; got ${message.thread_ts}`,
);
}
},
};
},
},
{
id: "slack-progress-commentary-true",
title: "Slack progress commentary true is independent from tool progress",
timeoutMs: 90_000,
configOverrides: {
progress: { commentary: true, toolProgress: false },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "draft",
toolProgress: "absent",
}),
},
{
id: "slack-progress-commentary-false",
title: "Slack progress commentary false stays out of the progress draft",
timeoutMs: 90_000,
configOverrides: {
progress: { commentary: false, toolProgress: false },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "absent",
toolProgress: "absent",
}),
},
{
id: "slack-progress-commentary-omitted",
title: "Slack omitted progress commentary preserves the tool-progress default",
timeoutMs: 90_000,
configOverrides: {
progress: { toolProgress: true },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "draft",
toolProgress: "draft",
}),
},
{
id: "slack-progress-commentary-verbose-dedupe",
title: "Slack explicit commentary yields to durable verbose progress",
timeoutMs: 90_000,
configOverrides: {
progress: { commentary: true, toolProgress: false, verboseDefault: "on" },
},
buildRun: (sutUserId) =>
buildSlackProgressCommentaryRun(sutUserId, {
commentary: "standalone",
toolProgress: "standalone",
}),
},
{
id: "slack-chart-presentation-native",
title: "Slack portable chart renders as a native data visualization",
timeoutMs: 90_000,
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const summaryText = `SLACK_QA_CHART_SUMMARY_${suffix}`;
const finalMarker = `SLACK_QA_CHART_DONE_${suffix}`;
const messageToolArgs = buildSlackChartMessageToolArgs(summaryText);
return {
expectReply: true,
input: [
`<@${sutUserId}> Slack native chart QA check ${summaryText}.`,
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
`After the chart send succeeds, reply with only this exact marker: ${finalMarker}`,
].join(" "),
matchText: finalMarker,
afterReply: async (_message, context) => {
await waitForSlackStoredMessage({
channelId: context.channelId,
client: context.sutReadClient,
description: "message with native chart",
matchesMessage: (message) =>
isExpectedSlackNativeChartMessage(
message,
renderSlackChartAccessibleText(summaryText),
),
oldestTs: context.sentTs,
sutIdentity: context.sutIdentity,
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
});
return "verified native data_visualization block and deterministic accessible text";
},
};
},
},
{
id: "slack-table-presentation-native",
title: "Slack portable table renders as a native data table",
timeoutMs: 90_000,
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const summaryText = `SLACK_QA_TABLE_SUMMARY_${suffix}`;
const finalMarker = `SLACK_QA_TABLE_DONE_${suffix}`;
const messageToolArgs = buildSlackTableMessageToolArgs(summaryText);
return {
expectReply: true,
input: [
`<@${sutUserId}> Slack native table QA check ${summaryText}.`,
`Call the message tool exactly once with these exact arguments: ${JSON.stringify(messageToolArgs)}.`,
`After the table send succeeds, reply with only this exact marker: ${finalMarker}`,
].join(" "),
matchText: finalMarker,
afterReply: async (_message, context) => {
await waitForSlackStoredMessage({
channelId: context.channelId,
client: context.sutReadClient,
description: "message with native table",
matchesMessage: (message) =>
isExpectedSlackNativeTableMessage(
message,
renderSlackTableAccessibleText(summaryText),
),
oldestTs: context.sentTs,
sutIdentity: context.sutIdentity,
timeoutMs: SLACK_QA_NATIVE_DATA_VERIFY_TIMEOUT_MS,
});
return "verified native data_table block and deterministic accessible text";
},
};
},
},
{
id: "slack-table-invalid-blocks-fallback",
title: "Slack rejects an over-limit native table and stores its complete fallback",
timeoutMs: 45_000,
buildRun: () => ({
kind: "direct-transport",
execute: runSlackTableInvalidBlocksFallbackScenario,
}),
},
{
id: "slack-reaction-glyph-native",
title: "Slack message tool normalizes an emoji glyph reaction",
timeoutMs: 90_000,
configOverrides: { messageTool: true },
buildRun: (sutUserId) => {
const token = `SLACK_QA_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
expectReply: true,
input: [
`<@${sutUserId}> use the message tool exactly once to react to this message.`,
'Set action to "react", channel to "slack", and emoji to exactly "✅".',
"Do not substitute a shortcode.",
`After the reaction succeeds, reply with only this exact marker: ${token}`,
].join(" "),
matchText: token,
afterReply: async (_message, context) => {
await waitForSlackReaction({
channelId: context.channelId,
client: context.sutReadClient,
expectedReactionName: "white_check_mark",
messageId: context.sentTs,
sutUserId: context.sutIdentity.userId,
timeoutMs: SLACK_QA_REACTION_VERIFY_TIMEOUT_MS,
});
return "verified SUT white_check_mark reaction from exact glyph instruction";
},
};
},
},
{
id: "slack-approval-exec-native",
title: "Slack native exec approval prompt resolves",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
target: "channel",
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
kind: "approval",
token: `SLACK_QA_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "slack-approval-plugin-native",
title: "Slack native plugin approval prompt resolves with exec approvals enabled",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
},
buildRun: () => ({
approvalKind: "plugin",
decision: "allow-once",
kind: "approval",
token: `SLACK_QA_PLUGIN_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "slack-codex-approval-exec-native",
title: "Slack native Codex command approval prompt resolves",
timeoutMs: 180_000,
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
codexApproval: true,
},
forcedRuntime: "codex",
buildRun: () => ({
approvalKind: "plugin",
appServerMethod: "item/commandExecution/requestApproval",
decision: "allow-once",
kind: "codex-approval",
token: `SLACK_QA_CODEX_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "slack-codex-approval-plugin-native",
title: "Slack native Codex file approval prompt resolves",
timeoutMs: 180_000,
configOverrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
codexApproval: true,
},
forcedRuntime: "codex",
buildRun: () => ({
approvalKind: "plugin",
appServerMethod: "item/fileChange/requestApproval",
decision: "allow-once",
kind: "codex-approval",
token: `SLACK_QA_CODEX_FILE_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
];
export function listSlackQaScenarioCatalog() {
return SLACK_QA_SCENARIOS.map((scenario) => ({ id: scenario.id }));
}
export function getSlackQaScenarioDefinition(id: string) {
const scenario = SLACK_QA_SCENARIOS.find((candidate) => candidate.id === id);
if (!scenario) {
throw new Error(`unknown Slack QA scenario id: ${id}`);
}
return scenario;
}
@@ -11,8 +11,9 @@ import {
resolveWhatsAppQaScenarioTarget,
type WhatsAppObservedMessage,
type WhatsAppQaRuntimeEnv,
type WhatsAppQaScenarioImplementation,
type WhatsAppQaScenarioRun,
} from "./whatsapp-live.contracts.js";
import { getWhatsAppQaScenarioDefinition } from "./whatsapp-live.scenarios.js";
import { waitForWhatsAppChannelStable } from "./whatsapp-live.setup.js";
type AdapterFactory = NonNullable<QaRunnerCliRegistration["adapterFactory"]>;
@@ -20,12 +21,16 @@ type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>>;
type FlowPreparationInput = Parameters<NonNullable<AdapterDefinition["prepareFlow"]>>[0];
export type WhatsAppQaScenarioEnvironment = {
configureScenario: (implementation: WhatsAppQaScenarioImplementation) => Promise<{
run: WhatsAppQaScenarioRun;
}>;
driverAuthDir: string;
gateway: FlowPreparationInput["gateway"];
getDriver: () => WhatsAppQaDriverSession;
observedMessages: WhatsAppObservedMessage[];
replaceDriver: (driver: WhatsAppQaDriverSession) => Promise<void>;
runtimeEnv: WhatsAppQaRuntimeEnv;
scenario: { id: string; timeoutMs: number; title: string };
sutAccountId: string;
sutAuthDir: string;
};
@@ -55,70 +60,73 @@ export function createWhatsAppQaScenarioEnvironment(params: {
const observedMessages: WhatsAppObservedMessage[] = [];
const prepareFlow = async (input: FlowPreparationInput) => {
const scenarioId = input.config.whatsappScenarioId;
if (typeof scenarioId !== "string") {
return undefined;
}
const scenario = getWhatsAppQaScenarioDefinition(scenarioId);
if (scenario.requiresGroupJid && !params.runtimeEnv.groupJid) {
if (params.explicitScenarioSelection) {
throw new Error(
`Requested WhatsApp scenario ${scenario.id} requires groupJid in the credential payload`,
);
}
throw new QaSuiteScenarioSkipError(
`WhatsApp scenario ${scenario.id} requires groupJid in the credential payload`,
);
}
const scenarioRun = scenario.buildRun();
const resolvedTarget = resolveWhatsAppQaScenarioTarget({
groupJid: params.runtimeEnv.groupJid,
scenarioId: scenario.id,
target: scenarioRun.kind === "approval" ? (scenarioRun.target ?? "dm") : scenarioRun.target,
});
const groupJid = resolvedTarget.target === "group" ? resolvedTarget.groupJid : undefined;
const allowFrom =
scenarioRun.kind === "approval"
? [params.runtimeEnv.driverPhoneE164]
: scenarioRun.configMode === "open"
? ["*"]
: scenarioRun.configMode === "pairing"
? ["+15550000000"]
: [params.runtimeEnv.driverPhoneE164];
const dmPolicy =
scenarioRun.kind === "approval"
? "allowlist"
: scenarioRun.configMode === "open" || scenarioRun.configMode === "disabled"
? scenarioRun.configMode
: scenarioRun.configMode === "allowlist"
? "allowlist"
: "pairing";
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = buildWhatsAppQaConfig(snapshot.config as OpenClawConfig, {
allowFrom,
authDir: params.sutAuthDir,
dmPolicy,
groupJid,
ownerAllowFrom: [params.runtimeEnv.driverPhoneE164],
overrides: scenario.configOverrides,
sutAccountId: params.accountId,
});
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: resolveWhatsAppQaReplacePaths(params.accountId),
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
await waitForWhatsAppChannelStable(input.gateway as never, params.accountId);
return {
whatsappScenarioContext: {
configureScenario: async (implementation: WhatsAppQaScenarioImplementation) => {
if (implementation.requiresGroupJid && !params.runtimeEnv.groupJid) {
if (params.explicitScenarioSelection) {
throw new Error(
`Requested WhatsApp scenario ${input.scenarioId} requires groupJid in the credential payload`,
);
}
throw new QaSuiteScenarioSkipError(
`WhatsApp scenario ${input.scenarioId} requires groupJid in the credential payload`,
);
}
const run = implementation.buildRun();
const resolvedTarget = resolveWhatsAppQaScenarioTarget({
groupJid: params.runtimeEnv.groupJid,
scenarioId: input.scenarioId,
target: run.kind === "approval" ? (run.target ?? "dm") : run.target,
});
const groupJid = resolvedTarget.target === "group" ? resolvedTarget.groupJid : undefined;
const allowFrom =
run.kind === "approval"
? [params.runtimeEnv.driverPhoneE164]
: run.configMode === "open"
? ["*"]
: run.configMode === "pairing"
? ["+15550000000"]
: [params.runtimeEnv.driverPhoneE164];
const dmPolicy =
run.kind === "approval"
? "allowlist"
: run.configMode === "open" || run.configMode === "disabled"
? run.configMode
: run.configMode === "allowlist"
? "allowlist"
: "pairing";
const snapshot = await readLiveQaGatewayConfig(input.gateway);
const cfg = buildWhatsAppQaConfig(snapshot.config as OpenClawConfig, {
allowFrom,
authDir: params.sutAuthDir,
dmPolicy,
groupJid,
ownerAllowFrom: [params.runtimeEnv.driverPhoneE164],
overrides: implementation.configOverrides,
sutAccountId: params.accountId,
});
await patchLiveQaGatewayConfig({
gateway: input.gateway,
patch: cfg as Record<string, unknown>,
replacePaths: resolveWhatsAppQaReplacePaths(params.accountId),
timeoutMs: input.timeoutMs,
waitForConfigRestartSettle: input.waitForConfigRestartSettle,
});
await waitForWhatsAppChannelStable(input.gateway as never, params.accountId);
return { run };
},
driverAuthDir: params.driverAuthDir,
gateway: input.gateway,
getDriver: params.getDriver,
observedMessages,
replaceDriver: params.replaceDriver,
runtimeEnv: params.runtimeEnv,
scenario: {
id: input.scenarioId,
timeoutMs: input.timeoutMs,
title: input.scenarioTitle,
},
sutAccountId: params.accountId,
sutAuthDir: params.sutAuthDir,
} satisfies WhatsAppQaScenarioEnvironment,
@@ -7,8 +7,10 @@ import {
resolveWhatsAppQaScenarioTarget,
type WhatsAppObservedMessage,
type WhatsAppQaMessageScenarioContext,
type WhatsAppQaScenarioDefinition,
type WhatsAppQaScenarioImplementation,
type WhatsAppQaScenarioMetadata,
type WhatsAppQaScenarioResult,
type WhatsAppQaScenarioRun,
} from "./whatsapp-live.contracts.js";
import {
WHATSAPP_QA_TRANSIENT_DRIVER_ATTEMPTS,
@@ -20,16 +22,64 @@ import {
waitForNoWhatsAppReply,
waitForWhatsAppScenarioSutMessage,
} from "./whatsapp-live.operations.js";
import { getWhatsAppQaScenarioDefinition } from "./whatsapp-live.scenarios.js";
import { waitForWhatsAppChannelStable } from "./whatsapp-live.setup.js";
export {
whatsappQaGroupAudioGatingScenario,
whatsappQaGroupOutboundAudioScenario,
whatsappQaGroupOutboundMediaScenario,
whatsappQaGroupOutboundPollScenario,
whatsappQaInboundStructuredMessagesScenario,
whatsappQaMessageActionsScenario,
whatsappQaOutboundDocumentPreservesFilenameScenario,
whatsappQaOutboundPollScenario,
whatsappQaOutboundSendSerializationScenario,
} from "./whatsapp-live.scenario-implementations.capabilities.js";
export {
whatsappQaBroadcastGroupFanoutScenario,
whatsappQaCanaryScenario,
whatsappQaGroupActivationAlwaysScenario,
whatsappQaGroupPendingHistoryContextScenario,
whatsappQaGroupReplyToBotTriggersScenario,
whatsappQaGroupReplyToMessageScenario,
whatsappQaMentionGatingScenario,
whatsappQaReplyToMessageScenario,
whatsappQaReplyToModeBatchedScenario,
whatsappQaTopLevelReplyShapeScenario,
} from "./whatsapp-live.scenario-implementations.conversation.js";
export {
whatsappQaApprovalExecDenyNativeScenario,
whatsappQaApprovalExecGroupReactionNativeScenario,
whatsappQaApprovalExecNativeScenario,
whatsappQaApprovalExecReactionNativeScenario,
whatsappQaApprovalPluginNativeScenario,
whatsappQaGroupAllowlistBlockScenario,
whatsappQaReplyDeliveryShapeScenario,
whatsappQaStatusReactionLifecycleScenario,
whatsappQaStatusReactionsScenario,
whatsappQaStreamFinalMessageAccountingScenario,
} from "./whatsapp-live.scenario-implementations.delivery.js";
export {
whatsappQaAgentMessageActionReactScenario,
whatsappQaAgentMessageActionUploadFileScenario,
whatsappQaAudioPreflightScenario,
whatsappQaGroupAgentMessageActionReactScenario,
whatsappQaGroupAgentMessageActionUploadFileScenario,
whatsappQaInboundImageCaptionScenario,
whatsappQaInboundReactionNoTriggerScenario,
whatsappQaOutboundMediaMatrixScenario,
whatsappQaReplyContextIsolationScenario,
} from "./whatsapp-live.scenario-implementations.user-path.js";
async function runWhatsAppScenarioAttempt(params: {
environment: WhatsAppQaScenarioEnvironment;
scenario: WhatsAppQaScenarioDefinition;
implementation: WhatsAppQaScenarioImplementation;
run: WhatsAppQaScenarioRun;
scenario: WhatsAppQaScenarioMetadata;
}): Promise<WhatsAppQaScenarioResult> {
const driver = params.environment.getDriver();
const runtimeEnv = params.environment.runtimeEnv;
const scenarioRun = params.scenario.buildRun();
const scenarioRun = params.run;
const resolvedTarget = resolveWhatsAppQaScenarioTarget({
groupJid: runtimeEnv.groupJid,
scenarioId: params.scenario.id,
@@ -61,7 +111,7 @@ async function runWhatsAppScenarioAttempt(params: {
turnSourceTo: approvalTurnSourceTo,
});
return {
...buildWhatsAppQaScenarioResultBase(params.scenario),
...buildWhatsAppQaScenarioResultBase(params.scenario, params.implementation),
status: "pass",
details: `${scenarioRun.approvalKind} approval ${approval.approvalId} resolved ${scenarioRun.decision} in ${approval.rttMs}ms`,
rttMs: approval.rttMs,
@@ -164,7 +214,7 @@ async function runWhatsAppScenarioAttempt(params: {
}),
});
return {
...buildWhatsAppQaScenarioResultBase(params.scenario),
...buildWhatsAppQaScenarioResultBase(params.scenario, params.implementation),
status: "pass",
details: ["no reply", afterSendDetails].filter(Boolean).join("; "),
};
@@ -186,7 +236,7 @@ async function runWhatsAppScenarioAttempt(params: {
const responseObservedAt = new Date(reply.observedAt);
const rttMs = responseObservedAt.getTime() - requestStartedAt.getTime();
return {
...buildWhatsAppQaScenarioResultBase(params.scenario),
...buildWhatsAppQaScenarioResultBase(params.scenario, params.implementation),
status: "pass",
details: [`reply matched in ${rttMs}ms`, afterSendDetails, afterReplyDetails, batchDetails]
.filter(Boolean)
@@ -203,11 +253,23 @@ async function runWhatsAppScenarioAttempt(params: {
};
}
async function runWhatsAppScenario(environment: WhatsAppQaScenarioEnvironment, scenarioId: string) {
const scenario = getWhatsAppQaScenarioDefinition(scenarioId);
export async function runWhatsAppScenario(
environment: WhatsAppQaScenarioEnvironment,
implementation: WhatsAppQaScenarioImplementation,
) {
const scenario = environment.scenario;
const { run: configuredRun } = await environment.configureScenario(implementation);
for (let attempt = 1; attempt <= WHATSAPP_QA_TRANSIENT_DRIVER_ATTEMPTS; attempt += 1) {
try {
const result = await runWhatsAppScenarioAttempt({ environment, scenario });
// Retry with fresh markers and callback state while retaining the gateway config
// prepared from the equivalent first run.
const run = attempt === 1 ? configuredRun : implementation.buildRun();
const result = await runWhatsAppScenarioAttempt({
environment,
implementation,
run,
scenario,
});
return attempt === 1
? result
: { ...result, details: `${result.details}; driver reconnected ${attempt - 1}x` };
@@ -225,96 +287,5 @@ async function runWhatsAppScenario(environment: WhatsAppQaScenarioEnvironment, s
await environment.replaceDriver(nextDriver);
}
}
throw new Error(`WhatsApp scenario ${scenarioId} exhausted driver retries`);
throw new Error(`WhatsApp scenario ${scenario.id} exhausted driver retries`);
}
export const runWhatsAppCanaryScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-canary");
export const runWhatsAppMentionGatingScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-mention-gating");
export const runWhatsAppGroupPendingHistoryContextScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-group-pending-history-context");
export const runWhatsAppBroadcastGroupFanoutScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-broadcast-group-fanout");
export const runWhatsAppGroupActivationAlwaysScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-activation-always");
export const runWhatsAppGroupReplyToBotTriggersScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-group-reply-to-bot-triggers");
export const runWhatsAppTopLevelReplyShapeScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-top-level-reply-shape");
export const runWhatsAppReplyToMessageScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-reply-to-message");
export const runWhatsAppGroupReplyToMessageScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-reply-to-message");
export const runWhatsAppReplyToModeBatchedScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-reply-to-mode-batched");
export const runWhatsAppAgentMessageActionReactScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-agent-message-action-react");
export const runWhatsAppAgentMessageActionUploadFileScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-agent-message-action-upload-file");
export const runWhatsAppGroupAgentMessageActionReactScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-group-agent-message-action-react");
export const runWhatsAppGroupAgentMessageActionUploadFileScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-group-agent-message-action-upload-file");
export const runWhatsAppInboundReactionNoTriggerScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-inbound-reaction-no-trigger");
export const runWhatsAppReplyContextIsolationScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-reply-context-isolation");
export const runWhatsAppInboundImageCaptionScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-inbound-image-caption");
export const runWhatsAppAudioPreflightScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-audio-preflight");
export const runWhatsAppOutboundMediaMatrixScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-outbound-media-matrix");
export const runWhatsAppOutboundDocumentPreservesFilenameScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-outbound-document-preserves-filename");
export const runWhatsAppOutboundSendSerializationScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-outbound-send-serialization");
export const runWhatsAppOutboundPollScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-outbound-poll");
export const runWhatsAppGroupOutboundMediaScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-outbound-media");
export const runWhatsAppGroupOutboundAudioScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-outbound-audio");
export const runWhatsAppGroupOutboundPollScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-outbound-poll");
export const runWhatsAppMessageActionsScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-message-actions");
export const runWhatsAppInboundStructuredMessagesScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-inbound-structured-messages");
export const runWhatsAppGroupAudioGatingScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-audio-gating");
export const runWhatsAppReplyDeliveryShapeScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-reply-delivery-shape");
export const runWhatsAppStreamFinalMessageAccountingScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-stream-final-message-accounting");
export const runWhatsAppApprovalExecDenyNativeScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-approval-exec-deny-native");
export const runWhatsAppStatusReactionsScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-status-reactions");
export const runWhatsAppStatusReactionLifecycleScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-status-reaction-lifecycle");
export const runWhatsAppGroupAllowlistBlockScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-group-allowlist-block");
export const runWhatsAppApprovalExecNativeScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-approval-exec-native");
export const runWhatsAppApprovalExecReactionNativeScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-approval-exec-reaction-native");
export const runWhatsAppApprovalExecGroupReactionNativeScenario = (
context: WhatsAppQaScenarioEnvironment,
) => runWhatsAppScenario(context, "whatsapp-approval-exec-group-reaction-native");
export const runWhatsAppApprovalPluginNativeScenario = (context: WhatsAppQaScenarioEnvironment) =>
runWhatsAppScenario(context, "whatsapp-approval-plugin-native");
@@ -16,7 +16,7 @@ import type {
WhatsAppQaApprovalKind,
WhatsAppQaApprovalScenarioRun,
WhatsAppQaGateway,
WhatsAppQaScenarioDefinition,
WhatsAppQaScenarioMetadata,
} from "./whatsapp-live.contracts.js";
import { formatDiagnosticId } from "./whatsapp-live.operations.js";
@@ -210,7 +210,7 @@ async function waitForWhatsAppApprovalMessage(params: {
driver: WhatsAppQaDriverSession;
observedAfter?: Date;
observedMessages: WhatsAppObservedMessage[];
scenario: WhatsAppQaScenarioDefinition;
scenario: WhatsAppQaScenarioMetadata;
state: "pending" | "resolved";
sutPhoneE164: string;
timeoutMs: number;
@@ -267,7 +267,7 @@ export async function runWhatsAppApprovalScenario(params: {
gateway: WhatsAppQaGateway;
observedMessages: WhatsAppObservedMessage[];
run: WhatsAppQaApprovalScenarioRun;
scenario: WhatsAppQaScenarioDefinition;
scenario: WhatsAppQaScenarioMetadata;
sutAccountId: string;
sutPhoneE164: string;
turnSourceTo: string;
@@ -14,46 +14,6 @@ export type WhatsAppQaRuntimeEnv = {
groupJid?: string;
};
export type WhatsAppQaScenarioId =
| "whatsapp-approval-exec-deny-native"
| "whatsapp-approval-exec-group-reaction-native"
| "whatsapp-approval-exec-reaction-native"
| "whatsapp-agent-message-action-react"
| "whatsapp-agent-message-action-upload-file"
| "whatsapp-audio-preflight"
| "whatsapp-broadcast-group-fanout"
| "whatsapp-canary"
| "whatsapp-group-allowlist-block"
| "whatsapp-group-activation-always"
| "whatsapp-group-agent-message-action-react"
| "whatsapp-group-agent-message-action-upload-file"
| "whatsapp-group-audio-gating"
| "whatsapp-group-outbound-audio"
| "whatsapp-group-outbound-media"
| "whatsapp-group-outbound-poll"
| "whatsapp-group-pending-history-context"
| "whatsapp-group-reply-to-bot-triggers"
| "whatsapp-group-reply-to-message"
| "whatsapp-inbound-reaction-no-trigger"
| "whatsapp-inbound-image-caption"
| "whatsapp-inbound-structured-messages"
| "whatsapp-message-actions"
| "whatsapp-outbound-document-preserves-filename"
| "whatsapp-outbound-media-matrix"
| "whatsapp-outbound-poll"
| "whatsapp-outbound-send-serialization"
| "whatsapp-mention-gating"
| "whatsapp-reply-delivery-shape"
| "whatsapp-reply-context-isolation"
| "whatsapp-reply-to-message"
| "whatsapp-reply-to-mode-batched"
| "whatsapp-stream-final-message-accounting"
| "whatsapp-status-reaction-lifecycle"
| "whatsapp-status-reactions"
| "whatsapp-top-level-reply-shape"
| "whatsapp-approval-exec-native"
| "whatsapp-approval-plugin-native";
export type WhatsAppQaApprovalKind = "exec" | "plugin";
export type WhatsAppQaApprovalDecision = "allow-once" | "deny";
type WhatsAppQaApprovalDecisionMode = "reaction" | "rpc";
@@ -63,47 +23,6 @@ export function toWhatsAppQaError(error: unknown): Error {
return error instanceof Error ? error : new Error(formatErrorMessage(error));
}
const WHATSAPP_QA_SCENARIO_POSTURES = {
"whatsapp-agent-message-action-react": "user-path",
"whatsapp-agent-message-action-upload-file": "user-path",
"whatsapp-approval-exec-deny-native": "native-approval",
"whatsapp-approval-exec-group-reaction-native": "native-approval",
"whatsapp-approval-exec-native": "native-approval",
"whatsapp-approval-exec-reaction-native": "native-approval",
"whatsapp-approval-plugin-native": "native-approval",
"whatsapp-audio-preflight": "user-path",
"whatsapp-broadcast-group-fanout": "user-path",
"whatsapp-canary": "user-path",
"whatsapp-group-activation-always": "user-path",
"whatsapp-group-allowlist-block": "user-path",
"whatsapp-group-agent-message-action-react": "user-path",
"whatsapp-group-agent-message-action-upload-file": "user-path",
"whatsapp-group-audio-gating": "user-path",
"whatsapp-group-outbound-audio": "direct-gateway",
"whatsapp-group-outbound-media": "direct-gateway",
"whatsapp-group-outbound-poll": "direct-gateway",
"whatsapp-group-pending-history-context": "user-path",
"whatsapp-group-reply-to-bot-triggers": "user-path",
"whatsapp-group-reply-to-message": "user-path",
"whatsapp-inbound-image-caption": "user-path",
"whatsapp-inbound-reaction-no-trigger": "user-path",
"whatsapp-inbound-structured-messages": "user-path",
"whatsapp-mention-gating": "user-path",
"whatsapp-message-actions": "direct-gateway",
"whatsapp-outbound-document-preserves-filename": "direct-gateway",
"whatsapp-outbound-media-matrix": "direct-gateway",
"whatsapp-outbound-poll": "direct-gateway",
"whatsapp-outbound-send-serialization": "direct-gateway",
"whatsapp-reply-context-isolation": "direct-gateway",
"whatsapp-reply-delivery-shape": "direct-gateway",
"whatsapp-reply-to-message": "user-path",
"whatsapp-reply-to-mode-batched": "user-path",
"whatsapp-status-reaction-lifecycle": "user-path",
"whatsapp-status-reactions": "user-path",
"whatsapp-stream-final-message-accounting": "user-path",
"whatsapp-top-level-reply-shape": "user-path",
} satisfies Record<WhatsAppQaScenarioId, WhatsAppQaScenarioPosture>;
type WhatsAppQaMessageSendMode =
| {
kind?: "text";
@@ -124,7 +43,7 @@ export type WhatsAppQaGatewayRuntime = Pick<
export type WhatsAppQaGatewayCallContext = {
gateway: Pick<WhatsAppQaGatewayRuntime, "call">;
gatewayTarget: string;
scenarioId: WhatsAppQaScenarioId;
scenarioId: string;
sutAccountId: string;
};
export type WhatsAppQaObservedMessagesContext = {
@@ -145,7 +64,7 @@ export type WhatsAppQaMessageScenarioContext = {
gatewayWorkspaceDir: string;
recordObservedMessage: (message: WhatsAppQaDriverObservedMessage) => void;
requestStartedAt: Date;
scenarioId: WhatsAppQaScenarioId;
scenarioId: string;
scenarioTitle: string;
sent: { messageId?: string };
sutAccountId: string;
@@ -166,7 +85,7 @@ type WhatsAppQaResolvedScenarioTarget =
export function resolveWhatsAppQaScenarioTarget(params: {
groupJid?: string;
scenarioId: WhatsAppQaScenarioId;
scenarioId: string;
target: "dm" | "group";
}): WhatsAppQaResolvedScenarioTarget {
if (params.target === "dm") {
@@ -242,7 +161,7 @@ export type WhatsAppQaApprovalScenarioRun = {
token: string;
};
type WhatsAppQaScenarioRun = WhatsAppQaApprovalScenarioRun | WhatsAppQaMessageScenarioRun;
export type WhatsAppQaScenarioRun = WhatsAppQaApprovalScenarioRun | WhatsAppQaMessageScenarioRun;
export type WhatsAppQaConfigOverrides = {
actions?: boolean;
@@ -263,14 +182,17 @@ export type WhatsAppQaConfigOverrides = {
statusReactions?: boolean;
};
export type WhatsAppQaScenarioDefinition = {
id: WhatsAppQaScenarioId;
title: string;
timeoutMs: number;
export type WhatsAppQaScenarioImplementation = {
buildRun: () => WhatsAppQaScenarioRun;
configOverrides?: WhatsAppQaConfigOverrides;
posture: WhatsAppQaScenarioPosture;
requiresGroupJid?: boolean;
requiredPluginIds?: readonly string[];
};
export type WhatsAppQaScenarioMetadata = {
id: string;
timeoutMs: number;
title: string;
};
export interface WhatsAppObservedMessage extends WhatsAppQaDriverObservedMessage {
@@ -297,10 +219,13 @@ export type WhatsAppQaScenarioResult = {
title: string;
};
export function buildWhatsAppQaScenarioResultBase(scenario: WhatsAppQaScenarioDefinition) {
export function buildWhatsAppQaScenarioResultBase(
scenario: WhatsAppQaScenarioMetadata,
implementation: WhatsAppQaScenarioImplementation,
) {
return {
id: scenario.id,
title: scenario.title,
posture: WHATSAPP_QA_SCENARIO_POSTURES[scenario.id],
posture: implementation.posture,
};
}
@@ -7,10 +7,9 @@ import type {
WhatsAppQaGatewayCallContext,
WhatsAppQaGatewayRuntime,
WhatsAppQaMessageScenarioContext,
WhatsAppQaScenarioId,
} from "./whatsapp-live.contracts.js";
function buildWhatsAppQaIdempotencyKey(scenarioId: WhatsAppQaScenarioId, label: string) {
function buildWhatsAppQaIdempotencyKey(scenarioId: string, label: string) {
return `${scenarioId}:${label}:${randomUUID()}`;
}
@@ -13,12 +13,18 @@ import {
import { describe, expect, it, vi } from "vitest";
import { fingerprintQaCredentialId } from "../../qa-credentials-fingerprint.runtime.js";
import { readQaScenarioById } from "../../scenario-catalog.js";
import { requireFlowScenario } from "../../scenario-catalog.test-utils.js";
import { applyQaMergePatch, collectQaSuiteGatewayConfigPatch } from "../../suite-planning.js";
import { createWhatsAppQaScenarioEnvironment } from "./scenario-environment.js";
import { resolveWhatsAppQaScenarioIds } from "./scenario-selection.js";
import { runWhatsAppApprovalScenario } from "./whatsapp-live.approvals.js";
import { buildWhatsAppQaConfig, parseWhatsAppQaCredentialPayload } from "./whatsapp-live.config.js";
import { resolveWhatsAppQaMessageTargets } from "./whatsapp-live.contracts.js";
import {
resolveWhatsAppQaMessageTargets,
type WhatsAppQaScenarioImplementation,
type WhatsAppQaScenarioMetadata,
type WhatsAppQaScenarioRun,
} from "./whatsapp-live.contracts.js";
import {
callWhatsAppGatewayMessageAction,
callWhatsAppGatewayPoll,
@@ -27,7 +33,10 @@ import {
runWhatsAppStructuredInboundChecks,
waitForScenarioObservedMessage,
} from "./whatsapp-live.operations.js";
import { getWhatsAppQaScenarioDefinition } from "./whatsapp-live.scenarios.js";
import * as whatsappCapabilityScenarios from "./whatsapp-live.scenario-implementations.capabilities.js";
import * as whatsappConversationScenarios from "./whatsapp-live.scenario-implementations.conversation.js";
import * as whatsappDeliveryScenarios from "./whatsapp-live.scenario-implementations.delivery.js";
import * as whatsappUserPathScenarios from "./whatsapp-live.scenario-implementations.user-path.js";
import { unpackWhatsAppAuthArchive } from "./whatsapp-live.setup.js";
const runExecSpy = vi.hoisted(() =>
@@ -108,9 +117,8 @@ function createWhatsAppQaDriverMock(
};
}
type WhatsAppScenarioDefinition = ReturnType<typeof getWhatsAppQaScenarioDefinition>;
type WhatsAppScenarioRun = ReturnType<WhatsAppScenarioDefinition["buildRun"]>;
type WhatsAppMessageScenarioRun = Exclude<WhatsAppScenarioRun, { kind: "approval" }>;
type WhatsAppScenarioDefinition = WhatsAppQaScenarioMetadata & WhatsAppQaScenarioImplementation;
type WhatsAppMessageScenarioRun = Exclude<WhatsAppQaScenarioRun, { kind: "approval" }>;
type WhatsAppScenarioContext = Parameters<NonNullable<WhatsAppMessageScenarioRun["afterSend"]>>[0];
type WhatsAppQaConfigBase = Parameters<typeof testing.buildWhatsAppQaConfig>[0];
type WhatsAppQaConfigParams = Parameters<typeof testing.buildWhatsAppQaConfig>[1];
@@ -159,14 +167,44 @@ function buildWhatsAppQaConfigFixture(
});
}
type WhatsAppScenarioIdFilter = Parameters<typeof getWhatsAppQaScenarioDefinition>[0];
type WhatsAppScenarioIdFilter = string;
const whatsappScenarioImplementations = {
...whatsappCapabilityScenarios,
...whatsappConversationScenarios,
...whatsappDeliveryScenarios,
...whatsappUserPathScenarios,
} as Record<string, WhatsAppQaScenarioImplementation>;
function toWhatsAppScenarioExportName(id: string) {
const suffix = id
.slice("whatsapp-".length)
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join("");
return `whatsappQa${suffix}Scenario`;
}
function getWhatsAppScenario(id: string): WhatsAppScenarioDefinition {
const implementation = whatsappScenarioImplementations[toWhatsAppScenarioExportName(id)];
if (!implementation) {
throw new Error(`missing WhatsApp test implementation for ${id}`);
}
const scenario = requireFlowScenario(readQaScenarioById(id));
return {
...implementation,
id,
timeoutMs: scenario.execution.timeoutMs ?? 60_000,
title: scenario.title,
};
}
function findScenarios(ids: readonly string[]) {
return ids.map((id) => getWhatsAppQaScenarioDefinition(id));
return ids.map(getWhatsAppScenario);
}
function findWhatsAppScenario(id: WhatsAppScenarioIdFilter) {
return getWhatsAppQaScenarioDefinition(id);
return getWhatsAppScenario(id);
}
function updateObservedMessage(
@@ -202,7 +240,7 @@ const WHATSAPP_GROUP_CAPABILITY_SCENARIO_IDS = [
] as const satisfies readonly WhatsAppScenarioIdFilter[];
function findMockWhatsAppScenario(id: WhatsAppScenarioIdFilter) {
const scenario = getWhatsAppQaScenarioDefinition(id);
const scenario = getWhatsAppScenario(id);
const mockScenarioIds = new Set(resolveWhatsAppQaScenarioIds({ providerMode: "mock-openai" }));
if (!mockScenarioIds.has(id)) {
throw new Error(`missing WhatsApp mock-openai scenario ${id}`);
@@ -1114,14 +1152,19 @@ describe("WhatsApp QA live runtime", () => {
sutAuthDir: "/tmp/whatsapp-sut",
});
await prepareFlow({
config: { whatsappScenarioId: "whatsapp-canary" },
const prepared = await prepareFlow({
config: {},
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/whatsapp-output",
primaryModel: "mock-openai/gpt-5.6-luna",
scenarioId: "whatsapp-canary",
scenarioTitle: "WhatsApp DM canary",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
});
await prepared.whatsappScenarioContext.configureScenario(
findWhatsAppScenario("whatsapp-canary"),
);
const patchCall = gatewayCall.mock.calls.find(([method]) => method === "config.patch");
if (!patchCall) {
@@ -1152,16 +1195,19 @@ describe("WhatsApp QA live runtime", () => {
sutAuthDir: "/tmp/whatsapp-sut",
});
await expect(
prepareFlow({
config: { policyKey: "dmPolicy", policyValue: "disabled" },
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/whatsapp-output",
primaryModel: "mock-openai/gpt-5.6-luna",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
}),
).resolves.toBeUndefined();
const prepared = await prepareFlow({
config: { policyKey: "dmPolicy", policyValue: "disabled" },
gateway: { call: gatewayCall } as never,
outputDir: "/tmp/whatsapp-output",
primaryModel: "mock-openai/gpt-5.6-luna",
scenarioId: "whatsapp-access-control-dm-disabled",
scenarioTitle: "WhatsApp dmPolicy disabled stays quiet",
timeoutMs: 60_000,
waitForConfigRestartSettle: vi.fn(),
});
expect(prepared.whatsappScenarioContext.scenario.id).toBe(
"whatsapp-access-control-dm-disabled",
);
expect(gatewayCall).not.toHaveBeenCalled();
});
@@ -2084,7 +2130,7 @@ describe("WhatsApp QA live runtime", () => {
throw new Error("whatsapp-audio-preflight unexpectedly built an approval scenario run");
}
expect(scenario.requiredPluginIds).toEqual(["openai"]);
expect(readQaScenarioById(scenario.id).plugins).toEqual(["openai"]);
expect(scenarioRun.expectReply).toBe(true);
expect(scenarioRun.matchText).toBe("WHATSAPP_QA_AUDIO_TRANSCRIPT_OK");
expect(scenarioRun.sendMode).toMatchObject({
@@ -0,0 +1,408 @@
// QA Lab WhatsApp Gateway capability and structured-message scenarios.
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { WhatsAppQaScenarioImplementation } from "./whatsapp-live.contracts.js";
import {
WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_MARKER,
WHATSAPP_QA_ONE_PIXEL_PNG,
callWhatsAppGatewayMessageAction,
callWhatsAppGatewayPoll,
callWhatsAppGatewaySend,
callWhatsAppGatewaySendConcurrently,
createWhatsAppQaAudioOggOpusBuffer,
createWhatsAppQaPdfBuffer,
requireWhatsAppTriggerMessageId,
runWhatsAppStructuredInboundChecks,
waitForScenarioObservedMessage,
waitForWhatsAppScenarioSutMessage,
waitForWhatsAppSutReactionToTrigger,
writeWhatsAppQaWorkspaceFixture,
} from "./whatsapp-live.operations.js";
export const whatsappQaOutboundDocumentPreservesFilenameScenario: WhatsAppQaScenarioImplementation =
{
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_DOCUMENT_FILE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-report-${token}.pdf`,
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "document-filename",
mediaUrl: documentPath,
message: `${token}_CAPTION`,
});
const document = await waitForScenarioObservedMessage(context, {
observedAfter: documentStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.text.includes(`${token}_CAPTION`) &&
message.mediaFileName === `whatsapp-qa-report-${token}.pdf`,
});
return `document ${document.mediaFileName ?? "<missing filename>"} preserved`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before document filename check: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaOutboundSendSerializationScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_SERIAL_SEND_${randomUUID().slice(0, 8).toUpperCase()}`;
const markers = Array.from({ length: 5 }, (_, index) => `${token}_${index + 1}`);
return {
afterReply: async (_reply, context) => {
const sendsStartedAt = new Date();
await callWhatsAppGatewaySendConcurrently(
context,
markers.map((marker, index) => ({
label: `parallel-${index + 1}`,
message: marker,
})),
);
await Promise.all(
markers.map((marker) =>
waitForScenarioObservedMessage(context, {
observedAfter: sendsStartedAt,
match: (message) => message.kind === "text" && message.text.includes(marker),
}),
),
);
return `gateway parallel send delivered ${markers.length}/${markers.length} messages`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before parallel send checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaOutboundPollScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_OUTBOUND_POLL_${randomUUID().slice(0, 8).toUpperCase()}`;
const question = `${token} choose one`;
return {
afterReply: async (_reply, context) => {
const pollStartedAt = new Date();
await callWhatsAppGatewayPoll(context, {
label: "poll",
options: ["alpha", "beta"],
question,
});
const poll = await waitForScenarioObservedMessage(context, {
observedAfter: pollStartedAt,
match: (message) =>
message.kind === "poll" &&
message.poll?.question === question &&
message.poll.options.includes("alpha") &&
message.poll.options.includes("beta"),
});
return `poll observed with ${poll.poll?.options.length ?? 0} options`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before outbound poll check: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaGroupOutboundMediaScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_MEDIA_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const mediaRootToken = randomUUID().slice(0, 8);
const imagePath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG,
fileName: `whatsapp-qa-group-${mediaRootToken}.png`,
});
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-group-${mediaRootToken}.pdf`,
});
const imageStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "group-image",
mediaUrl: imagePath,
message: `${token}_IMAGE`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: imageStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_IMAGE`),
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "group-document",
mediaUrl: documentPath,
message: `${token}_DOCUMENT`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: documentStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true) &&
message.text.includes(`${token}_DOCUMENT`),
});
return "gateway send delivered image and document media to the group";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound media checks: ${token}`,
matchText: token,
target: "group",
};
},
};
export const whatsappQaGroupOutboundAudioScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_AUDIO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const audioPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaAudioOggOpusBuffer({ variant: "group-trigger" }),
fileName: `whatsapp-qa-group-audio-${token}.ogg`,
});
const audioStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
asVoice: true,
label: "group-audio",
mediaUrl: audioPath,
message: `${token}_AUDIO`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: audioStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("audio/") === true,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: audioStartedAt,
targetKind: "group",
match: (message) => message.text.includes(`${token}_AUDIO`),
});
return "gateway send delivered audio media to the group";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound audio check: ${token}`,
matchText: token,
target: "group",
};
},
};
export const whatsappQaGroupOutboundPollScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_POLL_${randomUUID().slice(0, 8).toUpperCase()}`;
const question = `${token} choose one`;
return {
afterReply: async (_reply, context) => {
const pollStartedAt = new Date();
await callWhatsAppGatewayPoll(context, {
label: "group-poll",
options: ["alpha", "beta"],
question,
});
const poll = await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: pollStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "poll" &&
message.poll?.question === question &&
message.poll.options.includes("alpha") &&
message.poll.options.includes("beta"),
});
return `group poll observed with ${poll.poll?.options.length ?? 0} options`;
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound poll check: ${token}`,
matchText: token,
target: "group",
};
},
};
export const whatsappQaMessageActionsScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_ACTIONS_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const triggerMessageId = requireWhatsAppTriggerMessageId(context);
const reactionStartedAt = new Date();
await callWhatsAppGatewayMessageAction(context, {
action: "react",
label: "react",
params: {
emoji: "👍",
messageId: triggerMessageId,
},
});
await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
observedAfter: reactionStartedAt,
});
const uploadStartedAt = new Date();
await callWhatsAppGatewayMessageAction(context, {
action: "upload-file",
label: "upload-file",
params: {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG.toString("base64"),
caption: `${token}_UPLOAD`,
contentType: "image/png",
filename: "whatsapp-qa-upload.png",
},
});
await waitForScenarioObservedMessage(context, {
observedAfter: uploadStartedAt,
match: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_UPLOAD`),
});
return "message.action react and upload-file observed";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before action checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaInboundStructuredMessagesScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
buildRun: () => {
const token = `WHATSAPP_QA_STRUCTURED_${randomUUID().slice(0, 8).toUpperCase()}`;
const locationToken = `${token}_LOCATION`;
const contactToken = `${token}_CONTACT`;
const stickerToken = `${token}_STICKER`;
const locationCoordinateText = "37.774900, -122.419400";
return {
afterReply: async (_reply, context) => {
const waitForStructuredReply = async (
label: string,
observedAfter: Date,
expectedToken: string,
) => {
try {
return await waitForScenarioObservedMessage(context, {
observedAfter,
timeoutMs: 60_000,
match: (message) => message.text.includes(expectedToken),
diagnosticChecks: [
{
label: "containsExpectedToken",
match: (message) => message.text.includes(expectedToken),
},
],
});
} catch (error) {
throw new Error(
`timed out waiting for WhatsApp structured ${label} reply (${expectedToken}): ${formatErrorMessage(error)}`,
{ cause: error },
);
}
};
await runWhatsAppStructuredInboundChecks({
contactToken,
documentToken: `${token}_DOCUMENT`,
driver: context.driver,
driverPhoneE164: context.driverPhoneE164,
locationToken,
stickerToken,
target: context.target,
waitForStructuredReply,
});
return "document, location, contact, and sticker elicited replies";
},
configMode: "allowlist",
expectReply: true,
input:
`When a later WhatsApp location message shows ${locationCoordinateText}, ` +
`reply with only this WhatsApp location marker: ${locationToken}. ` +
`When a later WhatsApp contact message appears, ` +
`reply with only this WhatsApp contact marker: ${contactToken}. ` +
`When a later WhatsApp sticker message appears, ` +
`reply with only this WhatsApp sticker marker: ${stickerToken}. ` +
`Reply with only this exact marker before structured inbound checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaGroupAudioGatingScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
audioPreflight: true,
},
requiresGroupJid: true,
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
input: "",
matchText: WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_MARKER,
quietInput: "",
quietSendMode: {
fileName: "whatsapp-qa-group-audio-quiet.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer(),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
quietWindowMs: 5_000,
sendMode: {
fileName: "whatsapp-qa-group-audio.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer({
variant: "group-trigger",
}),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
target: "group",
}),
};
@@ -0,0 +1,366 @@
// QA Lab WhatsApp conversation and reply-context scenarios.
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
toWhatsAppQaError,
type WhatsAppQaMessageScenarioRun,
type WhatsAppQaScenarioImplementation,
} from "./whatsapp-live.contracts.js";
import {
assertWhatsAppMessageFromSutPhone,
assertWhatsAppMessagesFromSutPhone,
buildWhatsAppQuotedMessageKeyFromObservedMessage,
resolveWhatsAppQaNoReplyTarget,
waitForDistinctWhatsAppSutMessages,
waitForNoWhatsAppReply,
waitForWhatsAppScenarioSutMessage,
} from "./whatsapp-live.operations.js";
function buildWhatsAppQuoteReplyRun(target: "dm" | "group"): WhatsAppQaMessageScenarioRun {
const token = `WHATSAPP_QA_REPLY_TO_${target.toUpperCase()}_${randomUUID().slice(0, 8).toUpperCase()}`;
const input =
target === "group"
? `openclawqa reply with only this exact marker: ${token}`
: `Reply with only this exact marker: ${token}`;
return {
configMode: "allowlist",
expectReply: true,
input,
matchText: token,
target,
verify: (reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
if (reply.quoted?.messageId !== context.sent.messageId) {
throw new Error(
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
}
export const whatsappQaCanaryScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
buildRun: () => {
const token = `WHATSAPP_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaMentionGatingScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
requiresGroupJid: true,
buildRun: () => {
const quietToken = `WHATSAPP_QA_GROUP_QUIET_${randomUUID().slice(0, 8).toUpperCase()}`;
const replyToken = `WHATSAPP_QA_GROUP_MENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker: ${replyToken}`,
matchText: replyToken,
quietInput: `This group message is intentionally unmentioned. If you respond, include ${quietToken}.`,
quietMatchText: quietToken,
quietWindowMs: 5_000,
target: "group",
};
},
};
export const whatsappQaGroupPendingHistoryContextScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
groupHistoryLimit: 50,
groupPolicy: "open",
inboundDebounceMs: 0,
replyToMode: "all",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const quietMarker = `WHATSAPP_QA_PENDING_HISTORY_QUIET_${suffix}`;
const contextSentinel = `WHATSAPP_QA_PENDING_HISTORY_CONTEXT_ONLY_${suffix}`;
const triggerMarker = `WHATSAPP_QA_PENDING_HISTORY_TRIGGER_${suffix}`;
const okMarker = `WHATSAPP_QA_PENDING_HISTORY_OK_${suffix}`;
return {
configMode: "open",
expectReply: true,
expectedSutMessageCount: 1,
input:
`openclawqa pending history context check ${triggerMarker}. ` +
`Reply with only ${okMarker} only if the previous quiet group message is present ` +
`in prior group context with its context-only sentinel. ` +
"Do not use current-message text as proof.",
matchText: okMarker,
quietInput: `quiet context marker ${quietMarker} ${contextSentinel}`,
quietWindowMs: 5_000,
target: "group",
};
},
};
export const whatsappQaBroadcastGroupFanoutScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
broadcast: {
agents: ["main", "qa-second"],
strategy: "sequential",
},
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_BROADCAST_TOKEN_${randomUUID().slice(0, 8).toUpperCase()}`;
const mainMarker = `${token}_MAIN`;
const secondMarker = `${token}_SECOND`;
return {
afterReply: async (reply, context) => {
const replies = await waitForDistinctWhatsAppSutMessages(context, {
initialMessages: [reply],
matchers: [
(message) => message.text.includes(mainMarker),
(message) => message.text.includes(secondMarker),
],
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
});
assertWhatsAppMessagesFromSutPhone(replies, context);
return "broadcast fanout produced main and qa-second replies";
},
configMode: "open",
expectReply: true,
input: `openclawqa broadcast fanout check ${token}`,
matchText: mainMarker,
target: "group",
};
},
};
export const whatsappQaGroupActivationAlwaysScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const alwaysMarker = `WHATSAPP_QA_ACTIVATION_ALWAYS_${suffix}`;
const quietMarker = `WHATSAPP_QA_ACTIVATION_QUIET_${suffix}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
let activationProbeError: unknown;
try {
const alwaysStartedAt = new Date();
await context.driver.sendText(
context.target,
`Group activation visible behavior marker ${alwaysMarker}`,
);
const alwaysReply = await waitForWhatsAppScenarioSutMessage(context, {
match: (message) => message.text.includes(alwaysMarker),
observedAfter: alwaysStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(alwaysReply, context);
} catch (error) {
activationProbeError = error;
}
let restoreError: unknown;
const restoreStartedAt = new Date();
try {
await context.driver.sendText(context.target, "/activation mention");
const restoreReply = await waitForWhatsAppScenarioSutMessage(context, {
match: (message) => /\bactivation\b.*\bmention\b/iu.test(message.text),
observedAfter: restoreStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(restoreReply, context);
} catch (error) {
restoreError = error;
}
if (activationProbeError && restoreError) {
throw new Error(
`activation always probe failed; additionally failed to restore mention mode: ${formatErrorMessage(restoreError)}`,
{ cause: activationProbeError },
);
}
if (activationProbeError) {
throw toWhatsAppQaError(activationProbeError);
}
if (restoreError) {
throw toWhatsAppQaError(restoreError);
}
const quietStartedAt = new Date();
await context.driver.sendText(
context.target,
`Group activation quiet marker ${quietMarker}`,
);
await waitForNoWhatsAppReply({
driver: context.driver,
observedAfter: quietStartedAt,
sutPhoneE164: context.sutPhoneE164,
windowMs: 5_000,
...resolveWhatsAppQaNoReplyTarget({
groupJid: context.target,
target: "group",
}),
});
return "activation always replied to an unmentioned group message and mention mode was restored";
},
configMode: "allowlist",
expectReply: true,
input: "/activation always",
matchText: /\bactivation\b.*\balways\b/iu,
target: "group",
};
},
};
export const whatsappQaGroupReplyToBotTriggersScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const seedMarker = `WHATSAPP_QA_REPLY_TO_BOT_SEED_${suffix}`;
const triggerMarker = `WHATSAPP_QA_REPLY_TO_BOT_TRIGGER_${suffix}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
const quotedStartedAt = new Date();
const quotedTrigger = await context.driver.sendText(
context.target,
`Quoted implicit reply trigger marker ${triggerMarker}`,
{
quotedMessageKey: buildWhatsAppQuotedMessageKeyFromObservedMessage(reply, {
remoteJid: context.target,
}),
},
);
if (!quotedTrigger.messageId) {
throw new Error("WhatsApp driver did not return a quoted trigger message id.");
}
const quotedTriggerMessageId = quotedTrigger.messageId;
const quotedReply = await waitForWhatsAppScenarioSutMessage(context, {
diagnosticChecks: [
{
label: "containsTriggerMarker",
match: (message) => message.text.includes(triggerMarker),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.text.includes(triggerMarker) &&
message.quoted?.messageId === quotedTriggerMessageId,
observedAfter: quotedStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(quotedReply, context);
return "quoted reply to bot triggered a group response without an explicit mention";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa Mentioned group seed marker ${seedMarker}`,
matchText: seedMarker,
target: "group",
};
},
};
export const whatsappQaTopLevelReplyShapeScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
replyToMode: "off",
},
buildRun: () => {
const token = `WHATSAPP_QA_TOP_LEVEL_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
verify: (reply) => {
if (reply.quoted?.messageId) {
throw new Error(
`expected top-level WhatsApp reply without quote metadata, got quoted message ${reply.quoted.messageId}`,
);
}
},
};
},
};
export const whatsappQaReplyToMessageScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
replyToMode: "all",
},
buildRun: () => buildWhatsAppQuoteReplyRun("dm"),
};
export const whatsappQaGroupReplyToMessageScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
replyToMode: "all",
},
requiresGroupJid: true,
buildRun: () => buildWhatsAppQuoteReplyRun("group"),
};
export const whatsappQaReplyToModeBatchedScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
inboundDebounceMs: 250,
replyToMode: "batched",
},
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const firstToken = `WHATSAPP_QA_BATCHED_FIRST_${suffix}`;
const finalToken = `WHATSAPP_QA_BATCHED_FINAL_${suffix}`;
let secondMessageId: string | undefined;
return {
afterSend: async (context) => {
const second = await context.driver.sendText(
context.target,
`Second batched WhatsApp QA message. Reply with only this exact marker: ${finalToken} only if the previous queued message is visible in this same run context.`,
);
secondMessageId = second.messageId;
return "second batched message sent before debounce flush";
},
configMode: "allowlist",
expectReply: true,
input: `First batched WhatsApp QA message ${firstToken}. Wait for the next message before replying.`,
matchText: finalToken,
target: "dm",
verify: (reply) => {
if (!secondMessageId) {
throw new Error("WhatsApp driver did not return a second batched message id.");
}
if (reply.quoted?.messageId !== secondMessageId) {
throw new Error(
`expected batched reply quote ${secondMessageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
},
};
@@ -0,0 +1,237 @@
// QA Lab WhatsApp delivery-shape, status, and approval scenarios.
import { randomUUID } from "node:crypto";
import type { WhatsAppQaScenarioImplementation } from "./whatsapp-live.contracts.js";
import {
callWhatsAppGatewaySend,
waitForScenarioObservedMessage,
waitForWhatsAppSutReactionSequenceToTrigger,
waitForWhatsAppSutReactionToTrigger,
} from "./whatsapp-live.operations.js";
export const whatsappQaReplyDeliveryShapeScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_REPLY_SHAPE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
const quotedTriggerMessageId = context.sent.messageId;
const chunkStartedAt = new Date();
const longText = `${token}_LONG_BEGIN\n${"A".repeat(4_500)}\n${token}_LONG_END`;
await callWhatsAppGatewaySend(context, {
label: "long-reply",
message: longText,
replyToId: quotedTriggerMessageId,
});
const firstChunk = await waitForScenarioObservedMessage(context, {
observedAfter: chunkStartedAt,
diagnosticChecks: [
{
label: "longBeginMarker",
match: (message) => message.text.includes(`${token}_LONG_BEGIN`),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.text.includes(`${token}_LONG_BEGIN`) &&
message.quoted?.messageId === quotedTriggerMessageId,
});
const secondChunk = await waitForScenarioObservedMessage(context, {
observedAfter: chunkStartedAt,
diagnosticChecks: [
{
label: "longEndMarker",
match: (message) => message.text.includes(`${token}_LONG_END`),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.messageId !== firstChunk.messageId &&
message.text.includes(`${token}_LONG_END`) &&
message.quoted?.messageId === quotedTriggerMessageId,
});
return `long reply chunked across ${firstChunk.messageId ?? "<first>"} and ${secondChunk.messageId ?? "<second>"}`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before reply-shape checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaStreamFinalMessageAccountingScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
expectedJoinedSutTextIncludes: ["WHATSAPP-LONG-FINAL-BEGIN", "WHATSAPP-LONG-FINAL-END"],
expectedSutMessageCount: 2,
input: "WhatsApp long final QA check. Use the scripted long final response.",
matchText: "WHATSAPP-LONG-FINAL-BEGIN",
settleMs: 4_000,
target: "dm",
}),
};
export const whatsappQaApprovalExecDenyNativeScenario: WhatsAppQaScenarioImplementation = {
posture: "native-approval",
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "deny",
kind: "approval",
token: `WHATSAPP_QA_EXEC_DENY_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const whatsappQaStatusReactionsScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
statusReactions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_STATUS_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { anyEmoji: true },
timeoutMs: 30_000,
});
return `status reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker after normal processing: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaStatusReactionLifecycleScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
statusReactions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_STATUS_LIFECYCLE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const reactions = await waitForWhatsAppSutReactionSequenceToTrigger(context, {
emojis: ["👀", "✅"],
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
});
for (const reaction of reactions) {
context.recordObservedMessage(reaction);
}
return `status reaction lifecycle observed ${reactions
.map((reaction) => reaction.reaction?.emoji ?? "<unknown>")
.join(" -> ")}`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker after normal processing: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaGroupAllowlistBlockScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
blockGroupSender: true,
groupPolicy: "allowlist",
},
requiresGroupJid: true,
buildRun: () => {
const quietToken = `WHATSAPP_QA_GROUP_BLOCK_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: false,
input: `openclawqa blocked group should not reply with ${quietToken}`,
matchText: quietToken,
target: "group",
};
},
};
export const whatsappQaApprovalExecNativeScenario: WhatsAppQaScenarioImplementation = {
posture: "native-approval",
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
kind: "approval",
token: `WHATSAPP_QA_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const whatsappQaApprovalExecReactionNativeScenario: WhatsAppQaScenarioImplementation = {
posture: "native-approval",
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
decisionMode: "reaction",
kind: "approval",
token: `WHATSAPP_QA_EXEC_REACTION_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const whatsappQaApprovalExecGroupReactionNativeScenario: WhatsAppQaScenarioImplementation = {
posture: "native-approval",
configOverrides: {
approvals: {
exec: true,
},
},
requiresGroupJid: true,
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
decisionMode: "reaction",
kind: "approval",
target: "group",
token: `WHATSAPP_QA_GROUP_EXEC_REACTION_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
export const whatsappQaApprovalPluginNativeScenario: WhatsAppQaScenarioImplementation = {
posture: "native-approval",
configOverrides: {
approvals: {
exec: true,
plugin: true,
},
},
buildRun: () => ({
approvalKind: "plugin",
decision: "allow-once",
kind: "approval",
token: `WHATSAPP_QA_PLUGIN_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
};
@@ -0,0 +1,382 @@
// QA Lab WhatsApp user-path action and inbound media scenarios.
import { randomUUID } from "node:crypto";
import type { WhatsAppQaScenarioImplementation } from "./whatsapp-live.contracts.js";
import {
WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
WHATSAPP_QA_AUDIO_TRANSCRIPT_MARKER,
WHATSAPP_QA_ONE_PIXEL_PNG,
assertWhatsAppMessageFromSutPhone,
callWhatsAppGatewaySend,
createWhatsAppQaAudioOggOpusBuffer,
createWhatsAppQaAudioWavBuffer,
createWhatsAppQaPdfBuffer,
matchesWhatsAppSutReactionToTrigger,
waitForNoWhatsAppReply,
waitForScenarioObservedMessage,
waitForWhatsAppScenarioSutMessage,
waitForWhatsAppSutReactionToTrigger,
writeWhatsAppQaWorkspaceFixture,
} from "./whatsapp-live.operations.js";
export const whatsappQaAgentMessageActionReactScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_AGENT_REACT_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
timeoutMs: 60_000,
});
return `agent message reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message, context) =>
matchesWhatsAppSutReactionToTrigger(message, context, { emoji: "👍" }),
configMode: "allowlist",
expectReply: false,
input:
`React to this WhatsApp message with thumbs up for QA action check ${token}. ` +
"Do not send any visible text reply after the reaction.",
matchText: token,
quietWindowMs: 8_000,
target: "dm",
};
},
};
export const whatsappQaAgentMessageActionUploadFileScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_AGENT_UPLOAD_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const media = await waitForScenarioObservedMessage(context, {
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
});
return `agent upload-file media ${media.mediaType ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
configMode: "allowlist",
expectReply: false,
input:
`Use the WhatsApp message tool upload-file action to send a PNG with caption ${token}. ` +
"Do not send any visible text reply after the upload.",
matchText: token,
quietWindowMs: 8_000,
target: "dm",
};
},
};
export const whatsappQaGroupAgentMessageActionReactScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
actions: true,
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_AGENT_REACT_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
timeoutMs: 60_000,
});
return `group agent message reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message, context) =>
matchesWhatsAppSutReactionToTrigger(message, context, { emoji: "👍" }),
configMode: "allowlist",
expectReply: false,
input:
`openclawqa react to this WhatsApp group message with thumbs up for QA action check ${token}. ` +
"Do not send any visible text reply after the reaction.",
matchText: token,
quietWindowMs: 8_000,
target: "group",
};
},
};
export const whatsappQaGroupAgentMessageActionUploadFileScenario: WhatsAppQaScenarioImplementation =
{
posture: "user-path",
configOverrides: {
actions: true,
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_AGENT_UPLOAD_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const media = await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: context.requestStartedAt,
targetKind: "group",
timeoutMs: 60_000,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
});
return `group agent upload-file media ${media.mediaType ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
configMode: "allowlist",
expectReply: false,
input:
`openclawqa use the WhatsApp message tool upload-file action to send a PNG with caption ${token}. ` +
"Do not send any visible text reply after the upload.",
matchText: token,
quietWindowMs: 8_000,
target: "group",
};
},
};
export const whatsappQaInboundReactionNoTriggerScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
buildRun: () => {
const token = `WHATSAPP_QA_INBOUND_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
if (!reply.messageId) {
throw new Error("WhatsApp SUT reply did not include a message id to react to.");
}
const reactionStartedAt = new Date();
await context.driver.sendReaction(context.target, reply.messageId, "❤️", {
fromMe: false,
});
await waitForNoWhatsAppReply({
driver: context.driver,
observedAfter: reactionStartedAt,
sutPhoneE164: context.sutPhoneE164,
target: "dm",
windowMs: 5_000,
});
return "driver reaction to SUT message did not trigger a fresh reply";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before inbound reaction check: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaReplyContextIsolationScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_REPLY_ISOLATION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
const quotedStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "quoted",
message: `${token}_QUOTED`,
replyToId: context.sent.messageId,
});
await waitForScenarioObservedMessage(context, {
observedAfter: quotedStartedAt,
diagnosticChecks: [
{
label: "textMarker",
match: (message) => message.text.includes(`${token}_QUOTED`),
},
{
label: "quotedMessageIdMatchesTrigger",
match: (message) => message.quoted?.messageId === context.sent.messageId,
},
],
match: (message) =>
message.text.includes(`${token}_QUOTED`) &&
message.quoted?.messageId === context.sent.messageId,
});
const freshStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "fresh",
message: `${token}_FRESH`,
});
const fresh = await waitForScenarioObservedMessage(context, {
observedAfter: freshStartedAt,
match: (message) => message.text.includes(`${token}_FRESH`),
});
if (fresh.quoted?.messageId) {
throw new Error(
`expected fresh WhatsApp send without quote metadata, got quoted message ${fresh.quoted.messageId}`,
);
}
return "quoted send and fresh send used independent reply context";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before reply isolation checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
export const whatsappQaInboundImageCaptionScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
buildRun: () => {
const token = `WHATSAPP_QA_IMAGE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `This image caption asks you to reply with only this exact marker: ${token}`,
matchText: token,
sendMode: {
fileName: "whatsapp-qa.png",
kind: "media",
mediaBuffer: WHATSAPP_QA_ONE_PIXEL_PNG,
mediaType: "image/png",
},
target: "dm",
};
},
};
export const whatsappQaAudioPreflightScenario: WhatsAppQaScenarioImplementation = {
posture: "user-path",
configOverrides: {
audioPreflight: true,
},
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
input: "",
matchText: WHATSAPP_QA_AUDIO_TRANSCRIPT_MARKER,
sendMode: {
fileName: "whatsapp-qa-audio.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer(),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
target: "dm",
}),
};
export const whatsappQaOutboundMediaMatrixScenario: WhatsAppQaScenarioImplementation = {
posture: "direct-gateway",
buildRun: () => {
const token = `WHATSAPP_QA_OUTBOUND_MEDIA_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const mediaRootToken = randomUUID().slice(0, 8);
const imagePath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG,
fileName: `whatsapp-qa-${mediaRootToken}.png`,
});
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-${mediaRootToken}.pdf`,
});
const audioPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaAudioWavBuffer(),
fileName: `whatsapp-qa-${mediaRootToken}.wav`,
});
const imageStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "image",
mediaUrl: imagePath,
message: `${token}_IMAGE`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: imageStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_IMAGE`),
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "document",
mediaUrl: documentPath,
message: `${token}_DOCUMENT`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: documentStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true) &&
message.text.includes(`${token}_DOCUMENT`),
});
const audioStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
asVoice: true,
label: "audio",
mediaUrl: audioPath,
message: `${token}_AUDIO`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: audioStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("audio/") === true,
});
await waitForScenarioObservedMessage(context, {
observedAfter: audioStartedAt,
match: (message) => message.text.includes(`${token}_AUDIO`),
});
const multiStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "multi",
mediaUrls: [imagePath, documentPath],
message: `${token}_MULTI`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: multiStartedAt,
match: (message) =>
message.kind === "media" && message.mediaType?.startsWith("image/") === true,
});
await waitForScenarioObservedMessage(context, {
observedAfter: multiStartedAt,
match: (message) =>
message.kind === "media" &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true),
});
return "gateway send delivered image, document, audio, and multi-media";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before outbound media checks: ${token}`,
matchText: token,
target: "dm",
};
},
};
@@ -1,420 +0,0 @@
// QA Lab WhatsApp Gateway capability and structured-message scenarios.
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { WhatsAppQaScenarioDefinition } from "./whatsapp-live.contracts.js";
import {
WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_MARKER,
WHATSAPP_QA_ONE_PIXEL_PNG,
callWhatsAppGatewayMessageAction,
callWhatsAppGatewayPoll,
callWhatsAppGatewaySend,
callWhatsAppGatewaySendConcurrently,
createWhatsAppQaAudioOggOpusBuffer,
createWhatsAppQaPdfBuffer,
requireWhatsAppTriggerMessageId,
runWhatsAppStructuredInboundChecks,
waitForScenarioObservedMessage,
waitForWhatsAppScenarioSutMessage,
waitForWhatsAppSutReactionToTrigger,
writeWhatsAppQaWorkspaceFixture,
} from "./whatsapp-live.operations.js";
export const WHATSAPP_QA_CAPABILITY_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-outbound-document-preserves-filename",
title: "WhatsApp direct Gateway document preserves filename and caption",
timeoutMs: 90_000,
buildRun: () => {
const token = `WHATSAPP_QA_DOCUMENT_FILE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-report-${token}.pdf`,
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "document-filename",
mediaUrl: documentPath,
message: `${token}_CAPTION`,
});
const document = await waitForScenarioObservedMessage(context, {
observedAfter: documentStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.text.includes(`${token}_CAPTION`) &&
message.mediaFileName === `whatsapp-qa-report-${token}.pdf`,
});
return `document ${document.mediaFileName ?? "<missing filename>"} preserved`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before document filename check: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-outbound-send-serialization",
title: "WhatsApp parallel Gateway sends deliver every outbound message",
timeoutMs: 90_000,
buildRun: () => {
const token = `WHATSAPP_QA_SERIAL_SEND_${randomUUID().slice(0, 8).toUpperCase()}`;
const markers = Array.from({ length: 5 }, (_, index) => `${token}_${index + 1}`);
return {
afterReply: async (_reply, context) => {
const sendsStartedAt = new Date();
await callWhatsAppGatewaySendConcurrently(
context,
markers.map((marker, index) => ({
label: `parallel-${index + 1}`,
message: marker,
})),
);
await Promise.all(
markers.map((marker) =>
waitForScenarioObservedMessage(context, {
observedAfter: sendsStartedAt,
match: (message) => message.kind === "text" && message.text.includes(marker),
}),
),
);
return `gateway parallel send delivered ${markers.length}/${markers.length} messages`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before parallel send checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-outbound-poll",
title: "WhatsApp direct Gateway poll delivers outbound native poll",
timeoutMs: 90_000,
buildRun: () => {
const token = `WHATSAPP_QA_OUTBOUND_POLL_${randomUUID().slice(0, 8).toUpperCase()}`;
const question = `${token} choose one`;
return {
afterReply: async (_reply, context) => {
const pollStartedAt = new Date();
await callWhatsAppGatewayPoll(context, {
label: "poll",
options: ["alpha", "beta"],
question,
});
const poll = await waitForScenarioObservedMessage(context, {
observedAfter: pollStartedAt,
match: (message) =>
message.kind === "poll" &&
message.poll?.question === question &&
message.poll.options.includes("alpha") &&
message.poll.options.includes("beta"),
});
return `poll observed with ${poll.poll?.options.length ?? 0} options`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before outbound poll check: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-group-outbound-media",
title: "WhatsApp direct Gateway send delivers media to a group",
timeoutMs: 120_000,
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_MEDIA_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const mediaRootToken = randomUUID().slice(0, 8);
const imagePath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG,
fileName: `whatsapp-qa-group-${mediaRootToken}.png`,
});
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-group-${mediaRootToken}.pdf`,
});
const imageStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "group-image",
mediaUrl: imagePath,
message: `${token}_IMAGE`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: imageStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_IMAGE`),
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "group-document",
mediaUrl: documentPath,
message: `${token}_DOCUMENT`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: documentStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true) &&
message.text.includes(`${token}_DOCUMENT`),
});
return "gateway send delivered image and document media to the group";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound media checks: ${token}`,
matchText: token,
target: "group",
};
},
},
{
id: "whatsapp-group-outbound-audio",
title: "WhatsApp direct Gateway send delivers audio to a group",
timeoutMs: 90_000,
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_AUDIO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const audioPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaAudioOggOpusBuffer({ variant: "group-trigger" }),
fileName: `whatsapp-qa-group-audio-${token}.ogg`,
});
const audioStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
asVoice: true,
label: "group-audio",
mediaUrl: audioPath,
message: `${token}_AUDIO`,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: audioStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("audio/") === true,
});
await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: audioStartedAt,
targetKind: "group",
match: (message) => message.text.includes(`${token}_AUDIO`),
});
return "gateway send delivered audio media to the group";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound audio check: ${token}`,
matchText: token,
target: "group",
};
},
},
{
id: "whatsapp-group-outbound-poll",
title: "WhatsApp direct Gateway poll delivers native poll to a group",
timeoutMs: 90_000,
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_OUTBOUND_POLL_${randomUUID().slice(0, 8).toUpperCase()}`;
const question = `${token} choose one`;
return {
afterReply: async (_reply, context) => {
const pollStartedAt = new Date();
await callWhatsAppGatewayPoll(context, {
label: "group-poll",
options: ["alpha", "beta"],
question,
});
const poll = await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: pollStartedAt,
targetKind: "group",
match: (message) =>
message.kind === "poll" &&
message.poll?.question === question &&
message.poll.options.includes("alpha") &&
message.poll.options.includes("beta"),
});
return `group poll observed with ${poll.poll?.options.length ?? 0} options`;
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker before group outbound poll check: ${token}`,
matchText: token,
target: "group",
};
},
},
{
id: "whatsapp-message-actions",
title: "WhatsApp direct Gateway message.action react and upload-file execute",
timeoutMs: 120_000,
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_ACTIONS_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const triggerMessageId = requireWhatsAppTriggerMessageId(context);
const reactionStartedAt = new Date();
await callWhatsAppGatewayMessageAction(context, {
action: "react",
label: "react",
params: {
emoji: "👍",
messageId: triggerMessageId,
},
});
await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
observedAfter: reactionStartedAt,
});
const uploadStartedAt = new Date();
await callWhatsAppGatewayMessageAction(context, {
action: "upload-file",
label: "upload-file",
params: {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG.toString("base64"),
caption: `${token}_UPLOAD`,
contentType: "image/png",
filename: "whatsapp-qa-upload.png",
},
});
await waitForScenarioObservedMessage(context, {
observedAfter: uploadStartedAt,
match: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_UPLOAD`),
});
return "message.action react and upload-file observed";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before action checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-inbound-structured-messages",
title: "WhatsApp inbound structured messages reach the agent",
timeoutMs: 240_000,
buildRun: () => {
const token = `WHATSAPP_QA_STRUCTURED_${randomUUID().slice(0, 8).toUpperCase()}`;
const locationToken = `${token}_LOCATION`;
const contactToken = `${token}_CONTACT`;
const stickerToken = `${token}_STICKER`;
const locationCoordinateText = "37.774900, -122.419400";
return {
afterReply: async (_reply, context) => {
const waitForStructuredReply = async (
label: string,
observedAfter: Date,
expectedToken: string,
) => {
try {
return await waitForScenarioObservedMessage(context, {
observedAfter,
timeoutMs: 60_000,
match: (message) => message.text.includes(expectedToken),
diagnosticChecks: [
{
label: "containsExpectedToken",
match: (message) => message.text.includes(expectedToken),
},
],
});
} catch (error) {
throw new Error(
`timed out waiting for WhatsApp structured ${label} reply (${expectedToken}): ${formatErrorMessage(error)}`,
{ cause: error },
);
}
};
await runWhatsAppStructuredInboundChecks({
contactToken,
documentToken: `${token}_DOCUMENT`,
driver: context.driver,
driverPhoneE164: context.driverPhoneE164,
locationToken,
stickerToken,
target: context.target,
waitForStructuredReply,
});
return "document, location, contact, and sticker elicited replies";
},
configMode: "allowlist",
expectReply: true,
input:
`When a later WhatsApp location message shows ${locationCoordinateText}, ` +
`reply with only this WhatsApp location marker: ${locationToken}. ` +
`When a later WhatsApp contact message appears, ` +
`reply with only this WhatsApp contact marker: ${contactToken}. ` +
`When a later WhatsApp sticker message appears, ` +
`reply with only this WhatsApp sticker marker: ${stickerToken}. ` +
`Reply with only this exact marker before structured inbound checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-group-audio-gating",
title: "WhatsApp group audio mention gating",
timeoutMs: 120_000,
configOverrides: {
audioPreflight: true,
},
requiredPluginIds: ["openai"],
requiresGroupJid: true,
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
input: "",
matchText: WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_MARKER,
quietInput: "",
quietSendMode: {
fileName: "whatsapp-qa-group-audio-quiet.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer(),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
quietWindowMs: 5_000,
sendMode: {
fileName: "whatsapp-qa-group-audio.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer({
variant: "group-trigger",
}),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
target: "group",
}),
},
];
@@ -1,379 +0,0 @@
// QA Lab WhatsApp conversation and reply-context scenarios.
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
toWhatsAppQaError,
type WhatsAppQaMessageScenarioRun,
type WhatsAppQaScenarioDefinition,
} from "./whatsapp-live.contracts.js";
import {
assertWhatsAppMessageFromSutPhone,
assertWhatsAppMessagesFromSutPhone,
buildWhatsAppQuotedMessageKeyFromObservedMessage,
resolveWhatsAppQaNoReplyTarget,
waitForDistinctWhatsAppSutMessages,
waitForNoWhatsAppReply,
waitForWhatsAppScenarioSutMessage,
} from "./whatsapp-live.operations.js";
function buildWhatsAppQuoteReplyRun(target: "dm" | "group"): WhatsAppQaMessageScenarioRun {
const token = `WHATSAPP_QA_REPLY_TO_${target.toUpperCase()}_${randomUUID().slice(0, 8).toUpperCase()}`;
const input =
target === "group"
? `openclawqa reply with only this exact marker: ${token}`
: `Reply with only this exact marker: ${token}`;
return {
configMode: "allowlist",
expectReply: true,
input,
matchText: token,
target,
verify: (reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
if (reply.quoted?.messageId !== context.sent.messageId) {
throw new Error(
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
}
export const WHATSAPP_QA_CONVERSATION_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-canary",
title: "WhatsApp DM canary",
timeoutMs: 60_000,
buildRun: () => {
const token = `WHATSAPP_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-mention-gating",
title: "WhatsApp group mention gating",
timeoutMs: 60_000,
requiresGroupJid: true,
buildRun: () => {
const quietToken = `WHATSAPP_QA_GROUP_QUIET_${randomUUID().slice(0, 8).toUpperCase()}`;
const replyToken = `WHATSAPP_QA_GROUP_MENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker: ${replyToken}`,
matchText: replyToken,
quietInput: `This group message is intentionally unmentioned. If you respond, include ${quietToken}.`,
quietMatchText: quietToken,
quietWindowMs: 5_000,
target: "group",
};
},
},
{
id: "whatsapp-group-pending-history-context",
title: "WhatsApp group pending history reaches mentioned turns",
timeoutMs: 90_000,
configOverrides: {
groupHistoryLimit: 50,
groupPolicy: "open",
inboundDebounceMs: 0,
replyToMode: "all",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const quietMarker = `WHATSAPP_QA_PENDING_HISTORY_QUIET_${suffix}`;
const contextSentinel = `WHATSAPP_QA_PENDING_HISTORY_CONTEXT_ONLY_${suffix}`;
const triggerMarker = `WHATSAPP_QA_PENDING_HISTORY_TRIGGER_${suffix}`;
const okMarker = `WHATSAPP_QA_PENDING_HISTORY_OK_${suffix}`;
return {
configMode: "open",
expectReply: true,
expectedSutMessageCount: 1,
input:
`openclawqa pending history context check ${triggerMarker}. ` +
`Reply with only ${okMarker} only if the previous quiet group message is present ` +
`in prior group context with its context-only sentinel. ` +
"Do not use current-message text as proof.",
matchText: okMarker,
quietInput: `quiet context marker ${quietMarker} ${contextSentinel}`,
quietWindowMs: 5_000,
target: "group",
};
},
},
{
id: "whatsapp-broadcast-group-fanout",
title: "WhatsApp group broadcast fans out to multiple agents",
timeoutMs: 120_000,
configOverrides: {
broadcast: {
agents: ["main", "qa-second"],
strategy: "sequential",
},
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_BROADCAST_TOKEN_${randomUUID().slice(0, 8).toUpperCase()}`;
const mainMarker = `${token}_MAIN`;
const secondMarker = `${token}_SECOND`;
return {
afterReply: async (reply, context) => {
const replies = await waitForDistinctWhatsAppSutMessages(context, {
initialMessages: [reply],
matchers: [
(message) => message.text.includes(mainMarker),
(message) => message.text.includes(secondMarker),
],
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
});
assertWhatsAppMessagesFromSutPhone(replies, context);
return "broadcast fanout produced main and qa-second replies";
},
configMode: "open",
expectReply: true,
input: `openclawqa broadcast fanout check ${token}`,
matchText: mainMarker,
target: "group",
};
},
},
{
id: "whatsapp-group-activation-always",
title: "WhatsApp group activation always wakes unmentioned messages",
timeoutMs: 120_000,
configOverrides: {
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const alwaysMarker = `WHATSAPP_QA_ACTIVATION_ALWAYS_${suffix}`;
const quietMarker = `WHATSAPP_QA_ACTIVATION_QUIET_${suffix}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
let activationProbeError: unknown;
try {
const alwaysStartedAt = new Date();
await context.driver.sendText(
context.target,
`Group activation visible behavior marker ${alwaysMarker}`,
);
const alwaysReply = await waitForWhatsAppScenarioSutMessage(context, {
match: (message) => message.text.includes(alwaysMarker),
observedAfter: alwaysStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(alwaysReply, context);
} catch (error) {
activationProbeError = error;
}
let restoreError: unknown;
const restoreStartedAt = new Date();
try {
await context.driver.sendText(context.target, "/activation mention");
const restoreReply = await waitForWhatsAppScenarioSutMessage(context, {
match: (message) => /\bactivation\b.*\bmention\b/iu.test(message.text),
observedAfter: restoreStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(restoreReply, context);
} catch (error) {
restoreError = error;
}
if (activationProbeError && restoreError) {
throw new Error(
`activation always probe failed; additionally failed to restore mention mode: ${formatErrorMessage(restoreError)}`,
{ cause: activationProbeError },
);
}
if (activationProbeError) {
throw toWhatsAppQaError(activationProbeError);
}
if (restoreError) {
throw toWhatsAppQaError(restoreError);
}
const quietStartedAt = new Date();
await context.driver.sendText(
context.target,
`Group activation quiet marker ${quietMarker}`,
);
await waitForNoWhatsAppReply({
driver: context.driver,
observedAfter: quietStartedAt,
sutPhoneE164: context.sutPhoneE164,
windowMs: 5_000,
...resolveWhatsAppQaNoReplyTarget({
groupJid: context.target,
target: "group",
}),
});
return "activation always replied to an unmentioned group message and mention mode was restored";
},
configMode: "allowlist",
expectReply: true,
input: "/activation always",
matchText: /\bactivation\b.*\balways\b/iu,
target: "group",
};
},
},
{
id: "whatsapp-group-reply-to-bot-triggers",
title: "WhatsApp group reply to bot wakes without an explicit mention",
timeoutMs: 120_000,
configOverrides: {
groupPolicy: "open",
},
requiresGroupJid: true,
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const seedMarker = `WHATSAPP_QA_REPLY_TO_BOT_SEED_${suffix}`;
const triggerMarker = `WHATSAPP_QA_REPLY_TO_BOT_TRIGGER_${suffix}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
const quotedStartedAt = new Date();
const quotedTrigger = await context.driver.sendText(
context.target,
`Quoted implicit reply trigger marker ${triggerMarker}`,
{
quotedMessageKey: buildWhatsAppQuotedMessageKeyFromObservedMessage(reply, {
remoteJid: context.target,
}),
},
);
if (!quotedTrigger.messageId) {
throw new Error("WhatsApp driver did not return a quoted trigger message id.");
}
const quotedTriggerMessageId = quotedTrigger.messageId;
const quotedReply = await waitForWhatsAppScenarioSutMessage(context, {
diagnosticChecks: [
{
label: "containsTriggerMarker",
match: (message) => message.text.includes(triggerMarker),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.text.includes(triggerMarker) &&
message.quoted?.messageId === quotedTriggerMessageId,
observedAfter: quotedStartedAt,
targetKind: "group",
timeoutMs: 60_000,
});
assertWhatsAppMessageFromSutPhone(quotedReply, context);
return "quoted reply to bot triggered a group response without an explicit mention";
},
configMode: "allowlist",
expectReply: true,
input: `openclawqa Mentioned group seed marker ${seedMarker}`,
matchText: seedMarker,
target: "group",
};
},
},
{
id: "whatsapp-top-level-reply-shape",
title: "WhatsApp DM top-level reply shape",
timeoutMs: 60_000,
configOverrides: {
replyToMode: "off",
},
buildRun: () => {
const token = `WHATSAPP_QA_TOP_LEVEL_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
verify: (reply) => {
if (reply.quoted?.messageId) {
throw new Error(
`expected top-level WhatsApp reply without quote metadata, got quoted message ${reply.quoted.messageId}`,
);
}
},
};
},
},
{
id: "whatsapp-reply-to-message",
title: "WhatsApp DM reply-to mode quotes the triggering message",
timeoutMs: 60_000,
configOverrides: {
replyToMode: "all",
},
buildRun: () => buildWhatsAppQuoteReplyRun("dm"),
},
{
id: "whatsapp-group-reply-to-message",
title: "WhatsApp group reply-to mode quotes the triggering message",
timeoutMs: 60_000,
configOverrides: {
replyToMode: "all",
},
requiresGroupJid: true,
buildRun: () => buildWhatsAppQuoteReplyRun("group"),
},
{
id: "whatsapp-reply-to-mode-batched",
title: "WhatsApp batched reply-to mode quotes the queued message",
timeoutMs: 90_000,
configOverrides: {
inboundDebounceMs: 250,
replyToMode: "batched",
},
buildRun: () => {
const suffix = randomUUID().slice(0, 8).toUpperCase();
const firstToken = `WHATSAPP_QA_BATCHED_FIRST_${suffix}`;
const finalToken = `WHATSAPP_QA_BATCHED_FINAL_${suffix}`;
let secondMessageId: string | undefined;
return {
afterSend: async (context) => {
const second = await context.driver.sendText(
context.target,
`Second batched WhatsApp QA message. Reply with only this exact marker: ${finalToken} only if the previous queued message is visible in this same run context.`,
);
secondMessageId = second.messageId;
return "second batched message sent before debounce flush";
},
configMode: "allowlist",
expectReply: true,
input: `First batched WhatsApp QA message ${firstToken}. Wait for the next message before replying.`,
matchText: finalToken,
target: "dm",
verify: (reply) => {
if (!secondMessageId) {
throw new Error("WhatsApp driver did not return a second batched message id.");
}
if (reply.quoted?.messageId !== secondMessageId) {
throw new Error(
`expected batched reply quote ${secondMessageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
);
}
},
};
},
},
];
@@ -1,250 +0,0 @@
// QA Lab WhatsApp delivery-shape, status, and approval scenarios.
import { randomUUID } from "node:crypto";
import type { WhatsAppQaScenarioDefinition } from "./whatsapp-live.contracts.js";
import {
callWhatsAppGatewaySend,
waitForScenarioObservedMessage,
waitForWhatsAppSutReactionSequenceToTrigger,
waitForWhatsAppSutReactionToTrigger,
} from "./whatsapp-live.operations.js";
export const WHATSAPP_QA_DELIVERY_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-reply-delivery-shape",
title: "WhatsApp direct Gateway send chunks long replies",
timeoutMs: 120_000,
buildRun: () => {
const token = `WHATSAPP_QA_REPLY_SHAPE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
const quotedTriggerMessageId = context.sent.messageId;
const chunkStartedAt = new Date();
const longText = `${token}_LONG_BEGIN\n${"A".repeat(4_500)}\n${token}_LONG_END`;
await callWhatsAppGatewaySend(context, {
label: "long-reply",
message: longText,
replyToId: quotedTriggerMessageId,
});
const firstChunk = await waitForScenarioObservedMessage(context, {
observedAfter: chunkStartedAt,
diagnosticChecks: [
{
label: "longBeginMarker",
match: (message) => message.text.includes(`${token}_LONG_BEGIN`),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.text.includes(`${token}_LONG_BEGIN`) &&
message.quoted?.messageId === quotedTriggerMessageId,
});
const secondChunk = await waitForScenarioObservedMessage(context, {
observedAfter: chunkStartedAt,
diagnosticChecks: [
{
label: "longEndMarker",
match: (message) => message.text.includes(`${token}_LONG_END`),
},
{
label: "quotesTrigger",
match: (message) => message.quoted?.messageId === quotedTriggerMessageId,
},
],
match: (message) =>
message.messageId !== firstChunk.messageId &&
message.text.includes(`${token}_LONG_END`) &&
message.quoted?.messageId === quotedTriggerMessageId,
});
return `long reply chunked across ${firstChunk.messageId ?? "<first>"} and ${secondChunk.messageId ?? "<second>"}`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before reply-shape checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-stream-final-message-accounting",
title: "WhatsApp streamed final response has exactly the final chunks",
timeoutMs: 90_000,
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
expectedJoinedSutTextIncludes: ["WHATSAPP-LONG-FINAL-BEGIN", "WHATSAPP-LONG-FINAL-END"],
expectedSutMessageCount: 2,
input: "WhatsApp long final QA check. Use the scripted long final response.",
matchText: "WHATSAPP-LONG-FINAL-BEGIN",
settleMs: 4_000,
target: "dm",
}),
},
{
id: "whatsapp-approval-exec-deny-native",
title: "WhatsApp native exec approval prompt denies",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "deny",
kind: "approval",
token: `WHATSAPP_QA_EXEC_DENY_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "whatsapp-status-reactions",
title: "WhatsApp status reactions are observable",
timeoutMs: 60_000,
configOverrides: {
statusReactions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_STATUS_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { anyEmoji: true },
timeoutMs: 30_000,
});
return `status reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker after normal processing: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-status-reaction-lifecycle",
title: "WhatsApp status reaction lifecycle updates the triggering message",
timeoutMs: 90_000,
configOverrides: {
statusReactions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_STATUS_LIFECYCLE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const reactions = await waitForWhatsAppSutReactionSequenceToTrigger(context, {
emojis: ["👀", "✅"],
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
});
for (const reaction of reactions) {
context.recordObservedMessage(reaction);
}
return `status reaction lifecycle observed ${reactions
.map((reaction) => reaction.reaction?.emoji ?? "<unknown>")
.join(" -> ")}`;
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker after normal processing: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-group-allowlist-block",
title: "WhatsApp group outside allowlist stays quiet",
timeoutMs: 8_000,
configOverrides: {
blockGroupSender: true,
groupPolicy: "allowlist",
},
requiresGroupJid: true,
buildRun: () => {
const quietToken = `WHATSAPP_QA_GROUP_BLOCK_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: false,
input: `openclawqa blocked group should not reply with ${quietToken}`,
matchText: quietToken,
target: "group",
};
},
},
{
id: "whatsapp-approval-exec-native",
title: "WhatsApp native exec approval prompt resolves",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
kind: "approval",
token: `WHATSAPP_QA_EXEC_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "whatsapp-approval-exec-reaction-native",
title: "WhatsApp native exec approval resolves from reaction",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
},
},
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
decisionMode: "reaction",
kind: "approval",
token: `WHATSAPP_QA_EXEC_REACTION_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "whatsapp-approval-exec-group-reaction-native",
title: "WhatsApp group-origin exec approval resolves from reaction",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
},
},
requiresGroupJid: true,
buildRun: () => ({
approvalKind: "exec",
decision: "allow-once",
decisionMode: "reaction",
kind: "approval",
target: "group",
token: `WHATSAPP_QA_GROUP_EXEC_REACTION_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
{
id: "whatsapp-approval-plugin-native",
title: "WhatsApp native plugin approval prompt resolves with exec approvals enabled",
timeoutMs: 60_000,
configOverrides: {
approvals: {
exec: true,
plugin: true,
},
},
buildRun: () => ({
approvalKind: "plugin",
decision: "allow-once",
kind: "approval",
token: `WHATSAPP_QA_PLUGIN_APPROVAL_${randomUUID().slice(0, 8).toUpperCase()}`,
}),
},
];
@@ -1,21 +0,0 @@
// QA Lab WhatsApp live scenario catalog and selection.
import type { WhatsAppQaScenarioDefinition } from "./whatsapp-live.contracts.js";
import { WHATSAPP_QA_CAPABILITY_SCENARIOS } from "./whatsapp-live.scenarios.capabilities.js";
import { WHATSAPP_QA_CONVERSATION_SCENARIOS } from "./whatsapp-live.scenarios.conversation.js";
import { WHATSAPP_QA_DELIVERY_SCENARIOS } from "./whatsapp-live.scenarios.delivery.js";
import { WHATSAPP_QA_USER_PATH_SCENARIOS } from "./whatsapp-live.scenarios.user-path.js";
const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
...WHATSAPP_QA_CONVERSATION_SCENARIOS,
...WHATSAPP_QA_USER_PATH_SCENARIOS,
...WHATSAPP_QA_CAPABILITY_SCENARIOS,
...WHATSAPP_QA_DELIVERY_SCENARIOS,
];
export function getWhatsAppQaScenarioDefinition(id: string) {
const scenario = WHATSAPP_QA_SCENARIOS.find((candidate) => candidate.id === id);
if (!scenario) {
throw new Error(`unknown WhatsApp QA scenario id: ${id}`);
}
return scenario;
}
@@ -1,394 +0,0 @@
// QA Lab WhatsApp user-path action and inbound media scenarios.
import { randomUUID } from "node:crypto";
import type { WhatsAppQaScenarioDefinition } from "./whatsapp-live.contracts.js";
import {
WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
WHATSAPP_QA_AUDIO_TRANSCRIPT_MARKER,
WHATSAPP_QA_ONE_PIXEL_PNG,
assertWhatsAppMessageFromSutPhone,
callWhatsAppGatewaySend,
createWhatsAppQaAudioOggOpusBuffer,
createWhatsAppQaAudioWavBuffer,
createWhatsAppQaPdfBuffer,
matchesWhatsAppSutReactionToTrigger,
waitForNoWhatsAppReply,
waitForScenarioObservedMessage,
waitForWhatsAppScenarioSutMessage,
waitForWhatsAppSutReactionToTrigger,
writeWhatsAppQaWorkspaceFixture,
} from "./whatsapp-live.operations.js";
export const WHATSAPP_QA_USER_PATH_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-agent-message-action-react",
title: "WhatsApp user-path agent reaction uses the message tool",
timeoutMs: 90_000,
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_AGENT_REACT_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
timeoutMs: 60_000,
});
return `agent message reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message, context) =>
matchesWhatsAppSutReactionToTrigger(message, context, { emoji: "👍" }),
configMode: "allowlist",
expectReply: false,
input:
`React to this WhatsApp message with thumbs up for QA action check ${token}. ` +
"Do not send any visible text reply after the reaction.",
matchText: token,
quietWindowMs: 8_000,
target: "dm",
};
},
},
{
id: "whatsapp-agent-message-action-upload-file",
title: "WhatsApp user-path agent upload-file sends media",
timeoutMs: 90_000,
configOverrides: {
actions: true,
},
buildRun: () => {
const token = `WHATSAPP_QA_AGENT_UPLOAD_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const media = await waitForScenarioObservedMessage(context, {
observedAfter: context.requestStartedAt,
timeoutMs: 60_000,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
});
return `agent upload-file media ${media.mediaType ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
configMode: "allowlist",
expectReply: false,
input:
`Use the WhatsApp message tool upload-file action to send a PNG with caption ${token}. ` +
"Do not send any visible text reply after the upload.",
matchText: token,
quietWindowMs: 8_000,
target: "dm",
};
},
},
{
id: "whatsapp-group-agent-message-action-react",
title: "WhatsApp group user-path agent reaction uses the message tool",
timeoutMs: 90_000,
configOverrides: {
actions: true,
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_AGENT_REACT_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const reaction = await waitForWhatsAppSutReactionToTrigger(context, {
expectation: { emoji: "👍" },
timeoutMs: 60_000,
});
return `group agent message reaction ${reaction.reaction?.emoji ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message, context) =>
matchesWhatsAppSutReactionToTrigger(message, context, { emoji: "👍" }),
configMode: "allowlist",
expectReply: false,
input:
`openclawqa react to this WhatsApp group message with thumbs up for QA action check ${token}. ` +
"Do not send any visible text reply after the reaction.",
matchText: token,
quietWindowMs: 8_000,
target: "group",
};
},
},
{
id: "whatsapp-group-agent-message-action-upload-file",
title: "WhatsApp group user-path agent upload-file sends media",
timeoutMs: 90_000,
configOverrides: {
actions: true,
},
requiresGroupJid: true,
buildRun: () => {
const token = `WHATSAPP_QA_GROUP_AGENT_UPLOAD_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterSend: async (context) => {
const media = await waitForWhatsAppScenarioSutMessage(context, {
observedAfter: context.requestStartedAt,
targetKind: "group",
timeoutMs: 60_000,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
});
return `group agent upload-file media ${media.mediaType ?? "<unknown>"} observed`;
},
allowQuietWindowMessage: (message) =>
message.kind === "media" &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(token),
configMode: "allowlist",
expectReply: false,
input:
`openclawqa use the WhatsApp message tool upload-file action to send a PNG with caption ${token}. ` +
"Do not send any visible text reply after the upload.",
matchText: token,
quietWindowMs: 8_000,
target: "group",
};
},
},
{
id: "whatsapp-inbound-reaction-no-trigger",
title: "WhatsApp inbound user reaction does not start a fresh run",
timeoutMs: 90_000,
buildRun: () => {
const token = `WHATSAPP_QA_INBOUND_REACTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (reply, context) => {
assertWhatsAppMessageFromSutPhone(reply, context);
if (!reply.messageId) {
throw new Error("WhatsApp SUT reply did not include a message id to react to.");
}
const reactionStartedAt = new Date();
await context.driver.sendReaction(context.target, reply.messageId, "❤️", {
fromMe: false,
});
await waitForNoWhatsAppReply({
driver: context.driver,
observedAfter: reactionStartedAt,
sutPhoneE164: context.sutPhoneE164,
target: "dm",
windowMs: 5_000,
});
return "driver reaction to SUT message did not trigger a fresh reply";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before inbound reaction check: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-reply-context-isolation",
title: "WhatsApp direct Gateway send does not reuse prior quote context",
timeoutMs: 120_000,
buildRun: () => {
const token = `WHATSAPP_QA_REPLY_ISOLATION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
if (!context.sent.messageId) {
throw new Error("WhatsApp driver did not return a triggering message id.");
}
const quotedStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "quoted",
message: `${token}_QUOTED`,
replyToId: context.sent.messageId,
});
await waitForScenarioObservedMessage(context, {
observedAfter: quotedStartedAt,
diagnosticChecks: [
{
label: "textMarker",
match: (message) => message.text.includes(`${token}_QUOTED`),
},
{
label: "quotedMessageIdMatchesTrigger",
match: (message) => message.quoted?.messageId === context.sent.messageId,
},
],
match: (message) =>
message.text.includes(`${token}_QUOTED`) &&
message.quoted?.messageId === context.sent.messageId,
});
const freshStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "fresh",
message: `${token}_FRESH`,
});
const fresh = await waitForScenarioObservedMessage(context, {
observedAfter: freshStartedAt,
match: (message) => message.text.includes(`${token}_FRESH`),
});
if (fresh.quoted?.messageId) {
throw new Error(
`expected fresh WhatsApp send without quote metadata, got quoted message ${fresh.quoted.messageId}`,
);
}
return "quoted send and fresh send used independent reply context";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before reply isolation checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-inbound-image-caption",
title: "WhatsApp inbound image caption reaches the agent",
timeoutMs: 60_000,
buildRun: () => {
const token = `WHATSAPP_QA_IMAGE_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `This image caption asks you to reply with only this exact marker: ${token}`,
matchText: token,
sendMode: {
fileName: "whatsapp-qa.png",
kind: "media",
mediaBuffer: WHATSAPP_QA_ONE_PIXEL_PNG,
mediaType: "image/png",
},
target: "dm",
};
},
},
{
id: "whatsapp-audio-preflight",
title: "WhatsApp inbound audio preflight transcript reaches the agent",
timeoutMs: 90_000,
configOverrides: {
audioPreflight: true,
},
requiredPluginIds: ["openai"],
buildRun: () => ({
configMode: "allowlist",
expectReply: true,
input: "",
matchText: WHATSAPP_QA_AUDIO_TRANSCRIPT_MARKER,
sendMode: {
fileName: "whatsapp-qa-audio.ogg",
kind: "media",
mediaBuffer: createWhatsAppQaAudioOggOpusBuffer(),
mediaType: WHATSAPP_QA_AUDIO_OGG_OPUS_MIME,
},
target: "dm",
}),
},
{
id: "whatsapp-outbound-media-matrix",
title: "WhatsApp direct Gateway send delivers outbound media variants",
timeoutMs: 120_000,
buildRun: () => {
const token = `WHATSAPP_QA_OUTBOUND_MEDIA_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
afterReply: async (_reply, context) => {
const mediaRootToken = randomUUID().slice(0, 8);
const imagePath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: WHATSAPP_QA_ONE_PIXEL_PNG,
fileName: `whatsapp-qa-${mediaRootToken}.png`,
});
const documentPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaPdfBuffer(),
fileName: `whatsapp-qa-${mediaRootToken}.pdf`,
});
const audioPath = await writeWhatsAppQaWorkspaceFixture(context, {
buffer: createWhatsAppQaAudioWavBuffer(),
fileName: `whatsapp-qa-${mediaRootToken}.wav`,
});
const imageStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "image",
mediaUrl: imagePath,
message: `${token}_IMAGE`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: imageStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("image/") === true &&
message.text.includes(`${token}_IMAGE`),
});
const documentStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
forceDocument: true,
label: "document",
mediaUrl: documentPath,
message: `${token}_DOCUMENT`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: documentStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true) &&
message.text.includes(`${token}_DOCUMENT`),
});
const audioStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
asVoice: true,
label: "audio",
mediaUrl: audioPath,
message: `${token}_AUDIO`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: audioStartedAt,
match: (message) =>
message.kind === "media" &&
message.hasMedia === true &&
message.mediaType?.startsWith("audio/") === true,
});
await waitForScenarioObservedMessage(context, {
observedAfter: audioStartedAt,
match: (message) => message.text.includes(`${token}_AUDIO`),
});
const multiStartedAt = new Date();
await callWhatsAppGatewaySend(context, {
label: "multi",
mediaUrls: [imagePath, documentPath],
message: `${token}_MULTI`,
});
await waitForScenarioObservedMessage(context, {
observedAfter: multiStartedAt,
match: (message) =>
message.kind === "media" && message.mediaType?.startsWith("image/") === true,
});
await waitForScenarioObservedMessage(context, {
observedAfter: multiStartedAt,
match: (message) =>
message.kind === "media" &&
(message.mediaType === "application/pdf" ||
message.mediaFileName?.endsWith(".pdf") === true),
});
return "gateway send delivered image, document, audio, and multi-media";
},
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker before outbound media checks: ${token}`,
matchText: token,
target: "dm",
};
},
},
];
@@ -8,7 +8,7 @@ import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "../live-transports/shared/credential-lease.runtime.js";
import { listSlackQaScenarioCatalog } from "../live-transports/slack/slack-live.scenarios.js";
import { resolveSlackQaScenarioIds } from "../live-transports/slack/scenario-selection.js";
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
import { createPhaseTimer, type MantisPhaseTimings } from "../mantis-phase-timer.runtime.js";
import {
@@ -214,12 +214,9 @@ function resolveScenarioIds(params: {
].join(", ")}. Unsupported: ${unsupported.join(", ")}.`,
);
}
const requested = new Set(scenarioIds);
// Slack selects scenarios from catalog order, not CLI order. The watcher
// must mirror that order or both sides can block on different checkpoints.
return listSlackQaScenarioCatalog()
.map((scenario) => scenario.id)
.filter((scenarioId) => requested.has(scenarioId));
// Mirror the YAML catalog order used by the Slack runner so the watcher
// and runner cannot block on different approval checkpoints.
return resolveSlackQaScenarioIds({ scenarioIds });
}
return scenarioIds;
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { qaScenarioModuleFlow } from "./scenario-module-flow.js";
describe("QA scenario module flow", () => {
it("resolves a module export argument against the loaded scenario module", () => {
const flow = qaScenarioModuleFlow.moduleSchema.parse({
module: "./scenario-runtime.js",
call: "runScenario",
args: [{ expr: "scenarioContext" }, { moduleExport: "scenarioImplementation" }],
});
expect(qaScenarioModuleFlow.resolveFlow(flow, "Scenario title")).toMatchObject({
steps: [
{
actions: [
{
set: "scenarioModule",
value: { expr: 'await qaImport("./scenario-runtime.js")' },
},
{
args: [
{ expr: "scenarioContext" },
{ expr: 'scenarioModule["scenarioImplementation"]' },
],
call: "scenarioModule.runScenario",
},
],
},
],
});
});
it("rejects malformed module export arguments", () => {
expect(() =>
qaScenarioModuleFlow.moduleSchema.parse({
module: "./scenario-runtime.js",
call: "runScenario",
args: [{ moduleExport: "" }],
}),
).toThrow("moduleExport arguments require a non-empty string export name");
});
});
+31 -2
View File
@@ -1,10 +1,29 @@
// QA Lab scenario module references normalize into the canonical flow shape.
import { z } from "zod";
const qaFlowModuleExportArgSchema = z
.object({
moduleExport: z.string().trim().min(1),
})
.strict();
const qaFlowModuleArgSchema = z.unknown().superRefine((arg, ctx) => {
if (
typeof arg !== "object" ||
arg === null ||
!("moduleExport" in arg) ||
qaFlowModuleExportArgSchema.safeParse(arg).success
) {
return;
}
ctx.addIssue({
code: "custom",
message: "moduleExport arguments require a non-empty string export name",
});
});
const qaFlowModuleSchema = z.object({
module: z.string().trim().min(1),
call: z.string().trim().min(1),
args: z.array(z.unknown()).optional(),
args: z.array(qaFlowModuleArgSchema).optional(),
});
const qaFlowExecutionShape = {
providerMode: z.enum(["aimock", "live-frontier", "mock-openai"]).optional(),
@@ -37,6 +56,16 @@ function normalizeQaScenarioFileMetadata<
};
}
function resolveQaScenarioModuleArg(arg: unknown) {
const parsed = qaFlowModuleExportArgSchema.safeParse(arg);
if (!parsed.success) {
return arg;
}
return {
expr: `scenarioModule[${JSON.stringify(parsed.data.moduleExport)}]`,
};
}
function resolveQaScenarioFileFlow<TFlow extends QaScenarioFlowShape>(
flow: TFlow | QaScenarioModuleFlow | undefined,
title: string,
@@ -55,7 +84,7 @@ function resolveQaScenarioFileFlow<TFlow extends QaScenarioFlowShape>(
},
{
call: `scenarioModule.${flow.call}`,
...(flow.args ? { args: flow.args } : {}),
...(flow.args ? { args: flow.args.map(resolveQaScenarioModuleArg) } : {}),
saveAs: "result",
},
],
@@ -340,6 +340,8 @@ export function createQaSuiteScenarioStepRunner(
gateway: env.gateway,
outputDir: env.outputDir,
primaryModel: env.primaryModel,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
timeoutMs: execution.timeoutMs ?? deps.liveTurnTimeoutMs(env, 60_000),
waitForConfigRestartSettle: async (options) =>
await waitForConfigRestartSettle(env, options?.restartDelayMs, options?.timeoutMs),
+2
View File
@@ -165,6 +165,8 @@ describe("qa suite", () => {
config: { expected: "value" },
gateway: env.gateway,
outputDir: "/tmp/qa-output",
scenarioId: "matrix-preparation-failure",
scenarioTitle: "matrix-preparation-failure",
timeoutMs: 45_000,
waitForConfigRestartSettle: expect.any(Function),
});
+4 -3
View File
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-canary }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordCanaryScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaCanaryScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-mention-gating }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordMentionGatingScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaMentionGatingScenario }
@@ -10,8 +10,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-native-help-command-registration }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordNativeHelpCommandRegistrationScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaNativeHelpCommandRegistrationScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 75000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-status-reactions-tool-only }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordStatusReactionsToolOnlyScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaStatusReactionsToolOnlyScenario }
@@ -12,8 +12,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-thread-reply-filepath-attachment }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordThreadReplyFilepathAttachmentScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaThreadReplyFilepathAttachmentScenario }
@@ -10,8 +10,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { discordScenarioId: discord-voice-autojoin }
flow:
module: ./live-transports/discord/scenario-runtime.js
call: runDiscordVoiceAutojoinScenario
args: [{ expr: discordScenarioContext }]
call: runDiscordScenario
args:
- { expr: discordScenarioContext }
- { moduleExport: discordQaVoiceAutojoinScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-allowlist-block }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackAllowlistBlockScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaAllowlistBlockScenario }
@@ -13,8 +13,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-approval-exec-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackApprovalExecNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaApprovalExecNativeScenario }
@@ -13,8 +13,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-approval-plugin-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackApprovalPluginNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaApprovalPluginNativeScenario }
+4 -3
View File
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-canary }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackCanaryScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaCanaryScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-channel-disabled-warning }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackChannelDisabledWarningScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaChannelDisabledWarningScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-chart-presentation-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackChartPresentationNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaChartPresentationNativeScenario }
@@ -14,8 +14,9 @@ scenario:
timeoutMs: 180000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-codex-approval-exec-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackCodexApprovalExecNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaCodexApprovalExecNativeScenario }
@@ -14,8 +14,9 @@ scenario:
timeoutMs: 180000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-codex-approval-plugin-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackCodexApprovalPluginNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaCodexApprovalPluginNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-mention-gating }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackMentionGatingScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaMentionGatingScenario }
@@ -13,8 +13,9 @@ scenario:
timeoutMs: 180000
retryCount: 0
suiteIsolation: isolated
config: { slackScenarioId: slack-mpim-app-mention-dedupe }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackMpimAppMentionDedupeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaMpimAppMentionDedupeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-progress-commentary-false }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackProgressCommentaryFalseScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaProgressCommentaryFalseScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-progress-commentary-omitted }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackProgressCommentaryOmittedScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaProgressCommentaryOmittedScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-progress-commentary-true }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackProgressCommentaryTrueScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaProgressCommentaryTrueScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-progress-commentary-verbose-dedupe }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackProgressCommentaryVerboseDedupeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaProgressCommentaryVerboseDedupeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-reaction-glyph-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackReactionGlyphNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaReactionGlyphNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-table-invalid-blocks-fallback }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackTableInvalidBlocksFallbackScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaTableInvalidBlocksFallbackScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-table-presentation-native }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackTablePresentationNativeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaTablePresentationNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
config: { slackScenarioId: slack-top-level-reply-shape }
flow:
module: ./live-transports/slack/scenario-runtime.js
call: runSlackTopLevelReplyShapeScenario
args: [{ expr: slackScenarioContext }]
call: runSlackScenario
args:
- { expr: slackScenarioContext }
- { moduleExport: slackQaTopLevelReplyShapeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-agent-message-action-react }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppAgentMessageActionReactScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaAgentMessageActionReactScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-agent-message-action-upload-file }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppAgentMessageActionUploadFileScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaAgentMessageActionUploadFileScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-approval-exec-deny-native }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppApprovalExecDenyNativeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaApprovalExecDenyNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-approval-exec-group-reaction-native }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppApprovalExecGroupReactionNativeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaApprovalExecGroupReactionNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-approval-exec-native }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppApprovalExecNativeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaApprovalExecNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-approval-exec-reaction-native }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppApprovalExecReactionNativeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaApprovalExecReactionNativeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-approval-plugin-native }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppApprovalPluginNativeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaApprovalPluginNativeScenario }
@@ -2,6 +2,8 @@ title: WhatsApp inbound audio preflight transcript reaches the agent
scenario:
id: whatsapp-audio-preflight
surface: channels
plugins:
- openai
coverage:
primary:
- whatsapp.inbound-media-download
@@ -11,8 +13,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-audio-preflight }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppAudioPreflightScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaAudioPreflightScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-broadcast-group-fanout }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppBroadcastGroupFanoutScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaBroadcastGroupFanoutScenario }
+4 -3
View File
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-canary }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppCanaryScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaCanaryScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-activation-always }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupActivationAlwaysScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupActivationAlwaysScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-agent-message-action-react }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupAgentMessageActionReactScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupAgentMessageActionReactScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-agent-message-action-upload-file }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupAgentMessageActionUploadFileScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupAgentMessageActionUploadFileScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-allowlist-block }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupAllowlistBlockScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupAllowlistBlockScenario }
@@ -2,6 +2,8 @@ title: WhatsApp group audio mention gating
scenario:
id: whatsapp-group-audio-gating
surface: channels
plugins:
- openai
coverage:
primary:
- whatsapp.group-allowlists
@@ -12,8 +14,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-audio-gating }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupAudioGatingScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupAudioGatingScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-outbound-audio }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupOutboundAudioScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupOutboundAudioScenario }
@@ -12,8 +12,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-outbound-media }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupOutboundMediaScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupOutboundMediaScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-outbound-poll }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupOutboundPollScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupOutboundPollScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-pending-history-context }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupPendingHistoryContextScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupPendingHistoryContextScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-reply-to-bot-triggers }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupReplyToBotTriggersScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupReplyToBotTriggersScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-group-reply-to-message }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppGroupReplyToMessageScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaGroupReplyToMessageScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-inbound-image-caption }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppInboundImageCaptionScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaInboundImageCaptionScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-inbound-reaction-no-trigger }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppInboundReactionNoTriggerScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaInboundReactionNoTriggerScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 240000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-inbound-structured-messages }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppInboundStructuredMessagesScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaInboundStructuredMessagesScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-mention-gating }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppMentionGatingScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaMentionGatingScenario }
@@ -12,8 +12,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-message-actions }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppMessageActionsScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaMessageActionsScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-outbound-document-preserves-filename }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppOutboundDocumentPreservesFilenameScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaOutboundDocumentPreservesFilenameScenario }
@@ -13,8 +13,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-outbound-media-matrix }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppOutboundMediaMatrixScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaOutboundMediaMatrixScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-outbound-poll }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppOutboundPollScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaOutboundPollScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-outbound-send-serialization }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppOutboundSendSerializationScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaOutboundSendSerializationScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-reply-context-isolation }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppReplyContextIsolationScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaReplyContextIsolationScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 120000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-reply-delivery-shape }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppReplyDeliveryShapeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaReplyDeliveryShapeScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-reply-to-message }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppReplyToMessageScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaReplyToMessageScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-reply-to-mode-batched }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppReplyToModeBatchedScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaReplyToModeBatchedScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-status-reaction-lifecycle }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppStatusReactionLifecycleScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaStatusReactionLifecycleScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-status-reactions }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppStatusReactionsScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaStatusReactionsScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 90000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-stream-final-message-accounting }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppStreamFinalMessageAccountingScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaStreamFinalMessageAccountingScenario }
@@ -11,8 +11,9 @@ scenario:
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated
config: { whatsappScenarioId: whatsapp-top-level-reply-shape }
flow:
module: ./live-transports/whatsapp/scenario-runtime.js
call: runWhatsAppTopLevelReplyShapeScenario
args: [{ expr: whatsappScenarioContext }]
call: runWhatsAppScenario
args:
- { expr: whatsappScenarioContext }
- { moduleExport: whatsappQaTopLevelReplyShapeScenario }
+2
View File
@@ -42,6 +42,8 @@ type QaRunnerMessageRecorder = {
type QaRunnerTransportFlowPreparationInput = {
config: Record<string, unknown>;
scenarioId: string;
scenarioTitle: string;
gateway: {
baseUrl: string;
tempRoot: string;