feat(qa): add Crabline Discord channel driver

This commit is contained in:
Dallin Romney
2026-08-03 01:06:41 +08:00
parent 17f18bc57b
commit a68bd01e10
32 changed files with 1121 additions and 40 deletions
+47 -8
View File
@@ -48,7 +48,7 @@ script aliases; both forms work.
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
| `qa buzz` | Live transport lane against a real Buzz relay room with dedicated driver and SUT identities. |
| `qa discord` | Live transport lane against a real private Discord guild channel. |
| `qa discord` | Discord transport lane through the real bundled plugin: live Discord by default, or Crabline's deterministic local provider server with `--channel-driver crabline`. |
| `qa matrix` | QA Lab Matrix catalog scenarios against a disposable Tuwunel homeserver. See [Matrix live lane](#matrix-live-lane). |
| `qa slack` | Live transport lane against a real private Slack channel. |
| `qa telegram` | Live transport lane against a real private Telegram group. |
@@ -458,8 +458,9 @@ guest can write back through the mounted workspace.
## Buzz, Discord, Slack, Telegram, and WhatsApp QA reference
The Matrix adapter uses the disposable Docker-backed lane documented above.
Buzz, Discord, Slack, Telegram, and WhatsApp run against pre-existing real
transports, so their reference lives here.
Buzz, Slack, Telegram, and WhatsApp run against pre-existing real transports.
Discord can run against either live Discord or Crabline's deterministic local
provider server. Their reference lives here.
### Shared CLI flags
@@ -481,10 +482,15 @@ accept the same flags:
| `--credential-file <path>` | - | Buzz-only JSON credential file for local runs. |
| `--allow-failures` | off | Write artifacts without returning a failing exit code when scenarios fail. |
Discord also accepts `--channel-driver <live|crabline>` (default `live`) and
`--list-scenarios`. Crabline mode is local and rejects `--credential-source`
and `--credential-role`; provider mode and model selection remain independent
of the channel driver.
Each lane exits non-zero on any failed scenario. `--allow-failures` writes
artifacts without setting a failing exit code. Telegram also accepts
`--list-scenarios` to print available scenario ids and exit; the other lanes
do not expose that flag.
artifacts without setting a failing exit code. Telegram and Discord also accept
`--list-scenarios` to print the scenario ids selected by the same profile,
provider, model, execution-kind, and channel-driver rules used for execution.
### Buzz QA
@@ -581,13 +587,46 @@ creating a separate RTT command or Telegram-specific summary format.
```bash
pnpm openclaw qa discord
pnpm openclaw qa discord --channel-driver live
```
Targets one real private Discord guild channel with two bots: a driver bot
Both commands select the live driver. They target one real private Discord
guild channel with two bots: a driver bot
controlled by the harness and a SUT bot started by the child OpenClaw gateway
through the bundled Discord plugin. Verifies channel mention handling, that
the SUT bot has registered the native `/help` command with Discord, and
opt-in Mantis evidence scenarios.
opt-in Mantis evidence scenarios. Live Discord remains the release/provider
coverage lane; catalog and profile membership determine its current scenario
inventory.
For deterministic local transport proof without Discord credentials or a
credential lease, run:
```bash
pnpm openclaw qa discord \
--channel-driver crabline \
--provider-mode mock-openai
pnpm openclaw qa suite \
--channel-driver crabline \
--channel discord \
--provider-mode mock-openai
```
Crabline starts a separate Discord-shaped local provider server, then the child
Gateway runs the real bundled OpenClaw Discord plugin against that server's REST
and Gateway/WebSocket boundaries. The harness injects provider-native inbound
events through Crabline's authenticated admin ingress and records the plugin's
outbound REST traffic. This is not Crabline's fixture-level Discord local mock
provider, which fixture commands call directly without running the OpenClaw
plugin.
The local server covers Gateway handshake and message events, REST message
delivery and reply metadata, mention handling, and native command registration.
Use live Discord for behavior the local server cannot faithfully exercise,
including voice, attachment/CDN and multipart behavior, interactions and
activities, webhooks, Gateway compression or multi-shard behavior, replay, and
Discord's distributed permissions and rate limits.
Required env when `--credential-source env`:
+2
View File
@@ -4,6 +4,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
import { registerQaLabCli } from "./src/cli.js";
import { registerCrablineDiscordProviderEndpoint } from "./src/crabline-discord-provider-endpoint.js";
import { createQaLabWebSearchProvider } from "./src/qa-web-search-provider.js";
import { createStaticSshWorkerProvider } from "./src/static-ssh-worker-provider.js";
@@ -18,6 +19,7 @@ export default definePluginEntry({
name: "QA Lab",
description: "Private QA automation harness and debugger UI",
register(api) {
registerCrablineDiscordProviderEndpoint(api);
api.registerTool(
{
name: "qa_restart_wait",
+2 -1
View File
@@ -9,7 +9,8 @@
}
],
"activation": {
"onStartup": false
"onStartup": false,
"onConfigPaths": ["plugins.entries.qa-lab"]
},
"contracts": {
"webSearchProviders": ["qa-lab-search"],
+28
View File
@@ -1210,6 +1210,34 @@ describe("qa cli runtime", () => {
});
});
it("dispatches generic suite Discord selection through Crabline", async () => {
await runQaSuiteCommand({
channelDriver: "crabline",
channel: "discord",
providerMode: "mock-openai",
scenarioIds: ["channel-canary"],
});
expect(runQaSuite).toHaveBeenCalledWith(
expect.objectContaining({
channelDriver: "crabline",
channelDriverSelection: expect.objectContaining({ channel: "discord" }),
scenarioIds: ["channel-canary"],
}),
);
});
it("rejects channels outside Crabline's canonical server contract", async () => {
await expect(
runQaSuiteCommand({
channelDriver: "crabline",
channel: "imessage",
providerMode: "mock-openai",
}),
).rejects.toThrow("--channel must be one of");
expect(runQaSuite).not.toHaveBeenCalled();
});
it("keeps implicit channel membership identical for live and Crabline drivers", async () => {
await runQaSuiteCommand({
channelDriver: "live",
@@ -0,0 +1,2 @@
export const CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT =
"crabline-discord-provider-endpoint.json";
@@ -0,0 +1,89 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, describe, expect, it, vi } from "vitest";
const setDiscordProviderEndpointDescriptor = vi.hoisted(() => vi.fn());
vi.mock("@openclaw/discord/provider-endpoint-api.js", () => ({
setDiscordProviderEndpointDescriptor,
}));
import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js";
import { registerCrablineDiscordProviderEndpoint } from "./crabline-discord-provider-endpoint.js";
const QA_TEMP_ROOT_ENV = "OPENCLAW_QA_TEMP_ROOT";
const originalTempRoot = process.env[QA_TEMP_ROOT_ENV];
afterEach(() => {
if (originalTempRoot === undefined) {
delete process.env[QA_TEMP_ROOT_ENV];
} else {
process.env[QA_TEMP_ROOT_ENV] = originalTempRoot;
}
setDiscordProviderEndpointDescriptor.mockReset();
});
describe("Crabline Discord child provider endpoint", () => {
it("loads the QA-owned artifact before startup and clears it during teardown", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-discord-endpoint-"));
process.env[QA_TEMP_ROOT_ENV] = tempRoot;
const descriptor = {
restApiBaseUrl: "http://127.0.0.1:43123/api/v10",
gatewayBotUrl: "http://127.0.0.1:43123/api/v10/gateway/bot",
gatewayOrigin: "ws://127.0.0.1:43123",
};
fs.writeFileSync(
path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT),
`${JSON.stringify(descriptor)}\n`,
{ mode: 0o600 },
);
const registerRuntimeLifecycle = vi.fn();
try {
registerCrablineDiscordProviderEndpoint(
createTestPluginApi({ registrationMode: "full", registerRuntimeLifecycle }),
);
expect(setDiscordProviderEndpointDescriptor).toHaveBeenCalledWith(descriptor);
expect(registerRuntimeLifecycle).toHaveBeenCalledOnce();
expect(setDiscordProviderEndpointDescriptor.mock.invocationCallOrder[0]).toBeLessThan(
registerRuntimeLifecycle.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
const lifecycle = registerRuntimeLifecycle.mock.calls[0]?.[0];
await lifecycle?.cleanup({ reason: "shutdown" });
expect(setDiscordProviderEndpointDescriptor).toHaveBeenLastCalledWith(undefined);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("does not load the artifact during non-runtime registration", () => {
process.env[QA_TEMP_ROOT_ENV] = "/does/not/exist";
registerCrablineDiscordProviderEndpoint(createTestPluginApi({ registrationMode: "discovery" }));
expect(setDiscordProviderEndpointDescriptor).not.toHaveBeenCalled();
});
it("rejects an artifact with fields outside the endpoint contract", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-discord-endpoint-"));
process.env[QA_TEMP_ROOT_ENV] = tempRoot;
fs.writeFileSync(
path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT),
JSON.stringify({ apiRoot: "http://127.0.0.1:43123/api", version: 1 }),
{ mode: 0o600 },
);
try {
expect(() =>
registerCrablineDiscordProviderEndpoint(createTestPluginApi({ registrationMode: "full" })),
).toThrow("Crabline Discord provider endpoint artifact is invalid");
expect(setDiscordProviderEndpointDescriptor).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import {
setDiscordProviderEndpointDescriptor,
type DiscordProviderEndpointDescriptor,
} from "@openclaw/discord/provider-endpoint-api.js";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js";
const QA_TEMP_ROOT_ENV = "OPENCLAW_QA_TEMP_ROOT";
const DESCRIPTOR_KEYS = ["gatewayBotUrl", "gatewayOrigin", "restApiBaseUrl"];
function readDescriptor(value: unknown): DiscordProviderEndpointDescriptor {
if (
!isRecord(value) ||
Object.keys(value).toSorted().join("\0") !== DESCRIPTOR_KEYS.join("\0") ||
typeof value.restApiBaseUrl !== "string" ||
typeof value.gatewayBotUrl !== "string" ||
typeof value.gatewayOrigin !== "string"
) {
throw new Error("Crabline Discord provider endpoint artifact is invalid");
}
return {
restApiBaseUrl: value.restApiBaseUrl,
gatewayBotUrl: value.gatewayBotUrl,
gatewayOrigin: value.gatewayOrigin,
};
}
export function registerCrablineDiscordProviderEndpoint(api: OpenClawPluginApi): void {
if (api.registrationMode !== "full") {
return;
}
const tempRoot = process.env[QA_TEMP_ROOT_ENV]?.trim();
if (!tempRoot) {
return;
}
const artifactPath = path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT);
let serialized: string;
try {
serialized = fs.readFileSync(artifactPath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw error;
}
const descriptor = readDescriptor(JSON.parse(serialized));
// Install before channel startup; the child Gateway owns clearing this process-local seam.
setDiscordProviderEndpointDescriptor(descriptor);
api.lifecycle.registerRuntimeLifecycle({
id: "qa-crabline-discord-provider-endpoint",
description: "Clears the QA-owned Discord provider endpoint when the child Gateway stops.",
cleanup: () => setDiscordProviderEndpointDescriptor(undefined),
});
}
@@ -0,0 +1,156 @@
// QA Lab tests cover the Discord-specific Crabline provider lifecycle.
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { createQaBusState } from "./bus-state.js";
import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js";
import { createQaCrablineTransportAdapter } from "./crabline-transport.js";
const DISCORD_SELECTION = {
capabilityMatrixPath: "crabline-channel-driver-capabilities.json",
channel: "discord",
channelDriver: "crabline",
providerReadinessArtifactPath: "crabline-provider-readiness.json",
} as const satisfies OpenClawCrablineChannelDriverSelection;
describe("Crabline Discord transport", () => {
it("stages the private endpoint and closes the provider after the Gateway phase", async () => {
await withTempDir("qa-crabline-transport-", async (outputDir) => {
const transport = await createQaCrablineTransportAdapter({
outputDir,
transportPolicy: { requireGroupMention: true, senderAllowlist: ["driver"] },
selection: DISCORD_SELECTION,
state: createQaBusState(),
});
const gatewayTempRoot = path.join(outputDir, "gateway-temp");
await fs.mkdir(gatewayTempRoot);
try {
expect(transport.requiredPluginIds).toEqual(["qa-lab", "discord"]);
const config = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" });
const discord = config.channels?.discord as
| {
applicationId?: string;
guilds?: Record<string, { channels?: Record<string, unknown>; users?: string[] }>;
token?: string;
}
| undefined;
expect(discord).toMatchObject({
allowFrom: [expect.stringMatching(/^\d{17,20}$/u)],
applicationId: expect.stringMatching(/^\d{17,20}$/u),
dmPolicy: "allowlist",
guilds: {
"*": {
channels: { "*": { enabled: true, requireMention: true } },
users: [expect.stringMatching(/^\d{17,20}$/u)],
},
},
token: expect.any(String),
});
await transport.stageGatewayRuntime?.({ tempRoot: gatewayTempRoot });
const descriptorPath = path.join(
gatewayTempRoot,
CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT,
);
const descriptor = JSON.parse(await fs.readFile(descriptorPath, "utf8")) as {
gatewayBotUrl: string;
gatewayOrigin: string;
restApiBaseUrl: string;
};
expect(descriptor).toEqual({
gatewayBotUrl: expect.stringMatching(
/^http:\/\/127\.0\.0\.1:\d+\/api\/v10\/gateway\/bot$/u,
),
gatewayOrigin: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/u),
restApiBaseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/api\/v10$/u),
});
if (process.platform !== "win32") {
expect((await fs.stat(descriptorPath)).mode & 0o077).toBe(0);
}
await expect(
transport.sendInbound({
conversation: { id: "discord-crabline-primary", kind: "group" },
senderId: "driver",
senderName: "QA Driver",
text: "@openclaw Discord provider marker.",
threadId: "discord-crabline-thread",
}),
).resolves.toMatchObject({
conversation: { id: "discord-crabline-primary", kind: "group" },
text: "@openclaw Discord provider marker.",
threadId: "discord-crabline-thread",
});
const delivery = transport.buildAgentDelivery({
target: "thread:discord-crabline-primary/discord-crabline-thread",
});
expect(delivery).toEqual({
channel: "discord",
replyChannel: "discord",
replyTo: expect.stringMatching(/^channel:\d{17,20}$/u),
to: expect.stringMatching(/^channel:\d{17,20}$/u),
});
const probeUrl = `${descriptor.restApiBaseUrl}/users/@me`;
const headers = { authorization: `Bot ${discord?.token}` };
const channelId = delivery.to?.replace(/^channel:/u, "");
const outboundResponse = await fetch(
`${descriptor.restApiBaseUrl}/channels/${channelId}/messages`,
{
body: JSON.stringify({ content: "Discord provider outbound marker." }),
headers: { ...headers, "content-type": "application/json" },
method: "POST",
},
);
expect(outboundResponse.ok).toBe(true);
await outboundResponse.body?.cancel();
await expect(
transport.waitForOutbound({
conversation: { id: "discord-crabline-primary", kind: "group" },
textIncludes: "Discord provider outbound marker.",
threadId: "discord-crabline-thread",
timeoutMs: 1_000,
}),
).resolves.toMatchObject({
conversation: { id: "discord-crabline-primary", kind: "group" },
text: "Discord provider outbound marker.",
threadId: "discord-crabline-thread",
});
const beforeCleanup = await fetch(probeUrl, { headers });
expect(beforeCleanup.ok).toBe(true);
await beforeCleanup.body?.cancel();
await transport.cleanup?.();
const beforeGatewayStop = await fetch(probeUrl, { headers });
expect(beforeGatewayStop.ok).toBe(true);
await beforeGatewayStop.body?.cancel();
await transport.cleanupAfterGatewayStop?.();
await expect(fetch(probeUrl, { headers })).rejects.toThrow();
} finally {
await transport.cleanupAfterGatewayStop?.();
}
});
});
it("completes Crabline's open Discord DM binding without a sender restriction", async () => {
await withTempDir("qa-crabline-discord-open-dm-", async (outputDir) => {
const transport = await createQaCrablineTransportAdapter({
outputDir,
selection: DISCORD_SELECTION,
state: createQaBusState(),
});
try {
expect(transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" })).toMatchObject({
channels: { discord: { allowFrom: ["*"], dmPolicy: "open" } },
});
} finally {
await transport.cleanupAfterGatewayStop?.();
}
});
});
});
@@ -13,6 +13,17 @@ const TELEGRAM_QA_MAX_NATIVE_ID = (1n << 52n) - 1n;
const MATTERMOST_ID_PATTERN = /^[a-z0-9]{26}$/u;
const MATRIX_QA_SERVER_NAME = "matrix-qa.test";
const MATRIX_QA_DRIVER_ID = `@driver:${MATRIX_QA_SERVER_NAME}`;
const DISCORD_ID_PATTERN = /^\d{17,20}$/u;
const DISCORD_ID_FLOOR = 100_000_000_000_000_000n;
export function resolveDiscordQaId(value: string) {
const trimmed = value.trim();
if (DISCORD_ID_PATTERN.test(trimmed)) {
return trimmed;
}
const digest = BigInt(`0x${createHash("sha256").update(trimmed).digest("hex").slice(0, 16)}`);
return String(DISCORD_ID_FLOOR + (digest % DISCORD_ID_FLOOR));
}
function resolveQaNumericId(value: string, range: bigint) {
const digest = BigInt(`0x${createHash("sha256").update(value).digest("hex").slice(0, 16)}`);
@@ -135,6 +146,30 @@ function resolveMatrixQaText(text: string, botUserId: string) {
);
}
function resolveDiscordQaText(text: string, botUserId: string) {
return text.replace(
/(^|[\s([{])@openclaw(?=$|[\s.,!?;)\]}])/gu,
(_match, prefix: string) => `${prefix}<@${botUserId}>`,
);
}
function resolveDiscordQaTarget(target: string) {
const normalized = target.trim();
if (normalized.startsWith("thread:")) {
const threadTarget = normalized.slice("thread:".length);
const separator = threadTarget.indexOf("/");
if (separator > 0) {
return `thread:${resolveDiscordQaId(threadTarget.slice(0, separator))}/${resolveDiscordQaId(threadTarget.slice(separator + 1))}`;
}
}
for (const prefix of ["channel:", "group:", "dm:", "user:"]) {
if (normalized.startsWith(prefix)) {
return `${prefix}${resolveDiscordQaId(normalized.slice(prefix.length))}`;
}
}
return resolveDiscordQaId(normalized);
}
function resolveTelegramQaTarget(target: string) {
const normalized = target.trim();
if (normalized.startsWith("thread:")) {
@@ -190,7 +225,9 @@ export function createCrablineProviderInboundInput(
? resolveMatrixQaConversationId(input.conversation.id)
: adapter.channel === "mattermost"
? resolveMattermostQaId(input.conversation.id)
: input.conversation.id,
: adapter.channel === "discord"
? resolveDiscordQaId(input.conversation.id)
: input.conversation.id,
kind,
},
senderId:
@@ -200,11 +237,18 @@ export function createCrablineProviderInboundInput(
? resolveMatrixQaSenderId(input.senderId)
: adapter.channel === "mattermost"
? resolveMattermostQaId(input.senderId)
: input.senderId,
: adapter.channel === "discord"
? resolveDiscordQaId(input.senderId)
: input.senderId,
text:
adapter.channel === "matrix" && adapter.manifest.provider === "matrix"
? resolveMatrixQaText(input.text, adapter.manifest.botUserId)
: input.text,
: adapter.channel === "discord" && adapter.manifest.provider === "discord"
? resolveDiscordQaText(input.text, adapter.manifest.botUserId)
: input.text,
...(input.threadId && adapter.channel === "discord"
? { threadId: resolveDiscordQaId(input.threadId) }
: {}),
};
}
@@ -213,7 +257,7 @@ export function resolveCrablineStateConversation(params: {
input: QaBusInboundMessageInput;
providerInbound: OpenClawCrablineInbound;
}) {
return ["mattermost", "matrix", "telegram"].includes(params.adapter.channel)
return ["discord", "mattermost", "matrix", "telegram"].includes(params.adapter.channel)
? params.input.conversation
: params.providerInbound.stateConversation;
}
@@ -230,11 +274,17 @@ export function createCrablineProviderDelivery(
? resolveMatrixQaTarget(target)
: adapter.channel === "mattermost"
? resolveMattermostQaTarget(target)
: target,
: adapter.channel === "discord"
? resolveDiscordQaTarget(target)
: target,
});
return {
delivery,
providerTargetKey:
adapter.channel === "matrix" ? delivery.to.replace(/^room:/u, "") : delivery.to,
adapter.channel === "matrix"
? delivery.to.replace(/^room:/u, "")
: adapter.channel === "discord"
? delivery.to.replace(/^(?:channel|user):/u, "")
: delivery.to,
};
}
+123 -8
View File
@@ -15,10 +15,12 @@ import {
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { createQaBusState, type QaBusState } from "./bus-state.js";
import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js";
import {
createCrablineProviderDelivery,
createCrablineProviderInboundInput,
resolveCrablineStateConversation,
resolveDiscordQaId,
resolveTelegramQaSenderId,
} from "./crabline-provider-targets.js";
import { discardIgnoredResponseBody } from "./ignored-response-body.js";
@@ -88,6 +90,11 @@ function formatLogicalQaTarget({ conversation, threadId }: QaBusInboundMessageIn
return threadId ? `thread:${conversation.id}/${threadId}` : `${prefix}:${conversation.id}`;
}
function formatLogicalQaConversationTarget({ conversation }: QaBusInboundMessageInput) {
const prefix = conversation.kind === "direct" ? "dm" : conversation.kind;
return `${prefix}:${conversation.id}`;
}
const TELEGRAM_LIFECYCLE_METHOD_RE = /\/(sendMessage|editMessageText|deleteMessage)$/u;
function readTelegramLifecycleEvent(params: {
@@ -212,6 +219,7 @@ function createCrablineState(params: {
}): QaCrablineTransportState {
const baseState = params.state;
const targetByProviderTarget = new Map<string, string>();
const logicalRouteByTarget = new Map<string, { target: string; threadId?: string }>();
const telegramMessageByProviderId = new Map<string, QaBusMessage>();
const pendingTelegramMessagesByChat = new Map<string, QaBusMessage[]>();
const outboundEvents: QaTransportOutboundEvent[] = [];
@@ -220,6 +228,7 @@ function createCrablineState(params: {
reset() {
baseState.reset();
targetByProviderTarget.clear();
logicalRouteByTarget.clear();
telegramMessageByProviderId.clear();
pendingTelegramMessagesByChat.clear();
outboundEvents.length = 0;
@@ -258,7 +267,16 @@ function createCrablineState(params: {
targetByProviderTarget,
}) as QaBusOutboundMessageInput | null;
if (outbound) {
baseState.addOutboundMessage(outbound);
const logicalRoute = logicalRouteByTarget.get(outbound.to);
baseState.addOutboundMessage(
logicalRoute
? {
...outbound,
to: logicalRoute.target,
...(logicalRoute.threadId ? { threadId: logicalRoute.threadId } : {}),
}
: outbound,
);
}
},
async addInboundMessage(input: QaBusInboundMessageInput) {
@@ -284,7 +302,12 @@ function createCrablineState(params: {
}
// Providers may coerce channel conversations to groups; preserve the scenario's logical
// target so outbound waits and assertions still match the original input.
targetByProviderTarget.set(providerInbound.providerTargetKey, formatLogicalQaTarget(input));
const logicalTarget = formatLogicalQaTarget(input);
targetByProviderTarget.set(providerInbound.providerTargetKey, logicalTarget);
logicalRouteByTarget.set(logicalTarget, {
target: formatLogicalQaConversationTarget(input),
...(input.threadId ? { threadId: input.threadId } : {}),
});
const providerMessageId = await postCrablineInbound({
adapter: params.adapter,
providerInbound,
@@ -297,7 +320,11 @@ function createCrablineState(params: {
input,
providerInbound,
}),
...(providerInbound.threadId ? { threadId: providerInbound.threadId } : {}),
...(input.threadId
? { threadId: input.threadId }
: providerInbound.threadId
? { threadId: providerInbound.threadId }
: {}),
},
providerMessageId,
);
@@ -320,6 +347,7 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
readonly #selection: OpenClawCrablineChannelDriverSelection;
readonly #transportPolicy?: QaTransportPolicy;
readonly #state: QaCrablineTransportState;
#cleanupPromise?: Promise<void>;
readonly sendNativeCommand?: (input: QaTransportNativeCommandInput) => Promise<void>;
readonly waitForOutboundSequence?: (input: QaTransportOutboundSequenceMatch) => Promise<{
events: QaTransportOutboundEvent[];
@@ -336,7 +364,10 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
id: CRABLINE_TRANSPORT_ID,
label: `crabline local ${params.selection.channel}`,
accountId: params.adapter.accountId,
requiredPluginIds: params.adapter.requiredPluginIds,
requiredPluginIds:
params.selection.channel === "discord"
? ["qa-lab", ...params.adapter.requiredPluginIds]
: params.adapter.requiredPluginIds,
state: params.state,
});
this.#adapter = params.adapter;
@@ -367,6 +398,50 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
this.#selection.channel === "signal"
? normalizeCrablineSignalGatewayConfig(rawConfig)
: rawConfig;
if (this.#selection.channel === "discord") {
const discord = config.channels?.discord;
const senderAllowlist = this.#transportPolicy?.senderAllowlist?.map(resolveDiscordQaId);
// Crabline's local binding opens DMs, so the real Discord plugin also requires the matching
// allowlist. Keep an explicit sender restriction; otherwise its open policy uses a wildcard.
const dmAllowlist = senderAllowlist ?? discord?.allowFrom ?? ["*"];
const discordWithOpenDms = {
...discord,
allowFrom: [...dmAllowlist],
...(dmAllowlist.includes("*") ? {} : { dmPolicy: "allowlist" as const }),
};
const wildcardGuild = discord?.guilds?.["*"];
const wildcardChannel = wildcardGuild?.channels?.["*"];
if (!this.#transportPolicy?.requireGroupMention && !senderAllowlist) {
return {
...config,
channels: { ...config.channels, discord: discordWithOpenDms },
} as QaTransportGatewayConfig;
}
return {
...config,
channels: {
...config.channels,
discord: {
...discordWithOpenDms,
...(senderAllowlist ? { groupPolicy: "allowlist" as const } : {}),
guilds: {
...discord?.guilds,
"*": {
...wildcardGuild,
...(senderAllowlist ? { users: [...senderAllowlist] } : {}),
channels: {
...wildcardGuild?.channels,
"*": {
...wildcardChannel,
...(this.#transportPolicy?.requireGroupMention ? { requireMention: true } : {}),
},
},
},
},
},
},
} as QaTransportGatewayConfig;
}
if (this.#selection.channel !== "telegram") {
return config as QaTransportGatewayConfig;
}
@@ -414,6 +489,26 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
createRuntimeEnvPatch = () => this.#adapter.createProviderReadinessEnv({});
stageGatewayRuntime = async ({ tempRoot }: { tempRoot: string }) => {
if (this.#adapter.manifest.provider !== "discord") {
return;
}
const gatewayUrl = new URL(this.#adapter.manifest.endpoints.gatewayUrl);
await fs.writeFile(
path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT),
`${JSON.stringify(
{
restApiBaseUrl: `${this.#adapter.manifest.endpoints.apiRoot}/v10`,
gatewayBotUrl: this.#adapter.manifest.endpoints.gatewayBotUrl,
gatewayOrigin: gatewayUrl.origin,
},
null,
2,
)}\n`,
{ encoding: "utf8", flag: "wx", mode: 0o600 },
);
};
handleAction = async (_params: {
action: QaTransportActionName;
args: Record<string, unknown>;
@@ -428,8 +523,24 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter {
"No live channel service or external credential lease is required.",
];
#cleanupTransport() {
this.#cleanupPromise ??= this.#state.cleanup();
void this.#cleanupPromise.catch(() => {
this.#cleanupPromise = undefined;
});
return this.#cleanupPromise;
}
async cleanup() {
await this.#state.cleanup();
if (this.#selection.channel !== "discord") {
await this.#cleanupTransport();
}
}
async cleanupAfterGatewayStop() {
if (this.#selection.channel === "discord") {
await this.#cleanupTransport();
}
}
}
@@ -439,12 +550,16 @@ export async function createQaCrablineTransportAdapter(params: {
selection: OpenClawCrablineChannelDriverSelection;
state?: QaBusState;
}) {
const requiresTelegramPolicy =
const requiresGroupPolicy =
params.transportPolicy?.requireGroupMention === true ||
params.transportPolicy?.senderAllowlist !== undefined;
if (params.selection.channel !== "telegram" && requiresTelegramPolicy) {
if (
params.selection.channel !== "telegram" &&
params.selection.channel !== "discord" &&
requiresGroupPolicy
) {
throw new Error(
`Crabline ${params.selection.channel} does not support the requested group transport policy; use the Crabline Telegram bridge or a live channel adapter`,
`Crabline ${params.selection.channel} does not support the requested group transport policy; use the Crabline Telegram or Discord bridge, or a live channel adapter`,
);
}
const recorderPath = path.join(
@@ -0,0 +1,148 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import { describe, expect, it } from "vitest";
import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js";
import { runQaSuite } from "./suite-launch.runtime.js";
const RUN_DISCORD_CRABLINE_E2E = process.env.OPENCLAW_QA_DISCORD_CRABLINE_E2E === "1";
const SCENARIO_ID = "discord-crabline-roundtrip";
const EXPECTED_MARKER = "DISCORD-CRABLINE-ROUNDTRIP-OK";
type RecorderEvent = {
accepted?: boolean;
body?: Record<string, unknown>;
method?: string;
path?: string;
type?: string;
};
function readObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readString(value: unknown): string {
return typeof value === "string" ? value : "";
}
async function readRecorderEvents(recorderPath: string): Promise<RecorderEvent[]> {
const raw = await fs.readFile(recorderPath, "utf8");
if (raw.includes("discord.com") || raw.includes("discordapp.com")) {
throw new Error("Discord Crabline recorder contains a public Discord service target.");
}
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as RecorderEvent);
}
describe("Discord Crabline real-plugin roundtrip", () => {
it.runIf(RUN_DISCORD_CRABLINE_E2E)(
"crosses the real Discord REST and Gateway boundaries and flushes cleanup evidence",
async () => {
const repoRoot = process.cwd();
const outputDir = path.join(
repoRoot,
".artifacts",
"qa-e2e",
`discord-crabline-roundtrip-${process.pid}-${Date.now()}`,
);
const selection = resolveOpenClawCrablineChannelDriverSelection({ channel: "discord" });
const suite = await runQaSuite({
channelDriver: "crabline",
channelDriverSelection: selection,
channelId: "discord",
controlUiEnabled: false,
outputDir,
primaryModel: "mock-openai/gpt-5.6-luna",
providerMode: "mock-openai",
repoRoot,
scenarioIds: [SCENARIO_ID],
});
expect(suite.executionKind).toBe("flow");
expect(suite.result.scenarios).toEqual([
expect.objectContaining({ name: expect.any(String), status: "pass" }),
]);
const recorderPath = path.join(
suite.result.outputDir,
"artifacts",
"crabline",
"discord-provider-server.jsonl",
);
const events = await readRecorderEvents(recorderPath);
const gatewayMetadata = events.find(
(event) =>
event.type === "api" &&
event.method === "GET" &&
event.path === "/api/v10/gateway/bot" &&
event.accepted === true,
);
const gatewayIdentify = events.find(
(event) =>
event.type === "api" &&
event.method === "WS" &&
event.path === "/gateway" &&
readObject(event.body)?.op === 2 &&
event.accepted === true,
);
expect(gatewayMetadata).toBeDefined();
expect(gatewayIdentify).toBeDefined();
const inbound = events.find(
(event) =>
event.type === "admin" &&
event.method === "POST" &&
event.path === "/crabline/discord/inbound" &&
event.accepted === true,
);
const inboundBody = readObject(inbound?.body);
const inboundChannelId = readString(inboundBody?.channelId);
const parentChannelId = readString(inboundBody?.parentChannelId);
expect(inboundChannelId).toMatch(/^\d{17,20}$/u);
expect(parentChannelId).toMatch(/^\d{17,20}$/u);
expect(inboundChannelId).not.toBe(parentChannelId);
expect(readString(inboundBody?.content)).toMatch(/<@\d{17,20}>/u);
const outbound = events.find(
(event) =>
event.type === "api" &&
event.method === "POST" &&
event.path === `/api/v10/channels/${inboundChannelId}/messages` &&
readString(readObject(event.body)?.content).includes(EXPECTED_MARKER) &&
event.accepted === true,
);
const outboundBody = readObject(outbound?.body);
const messageReference = readObject(outboundBody?.message_reference);
expect(outbound).toBeDefined();
expect(readString(outboundBody?.content)).toContain(EXPECTED_MARKER);
expect(readString(outboundBody?.content)).not.toMatch(/<@\d{17,20}>/u);
expect(messageReference).toMatchObject({
message_id: expect.stringMatching(/^\d{17,20}$/u),
});
expect(
events.some(
(event) =>
event.type === "api" &&
event.accepted === true &&
(event.method === "PUT" || event.method === "POST") &&
/^\/api\/v10\/applications\/\d{17,20}\/commands$/u.test(event.path ?? ""),
),
).toBe(true);
// Suite return occurs only after the child Gateway and its WebSocket are stopped, then the
// Discord HTTP server and recorder are closed. The QA-owned descriptor lives in the removed
// child temp root and must never escape into durable suite artifacts.
await expect(
fs.access(path.join(suite.result.outputDir, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT)),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(recorderPath, "utf8")).resolves.toContain(EXPECTED_MARKER);
},
180_000,
);
});
+10 -4
View File
@@ -100,11 +100,14 @@ export type QaGatewayChildListeningContext = {
runtimeEnv: NodeJS.ProcessEnv;
};
function createQaGatewayEmptyTransport() {
function createQaGatewayEmptyTransport(): Pick<
QaTransportAdapter,
"requiredPluginIds" | "createGatewayConfig" | "stageGatewayRuntime"
> {
return {
requiredPluginIds: [] as const,
createGatewayConfig: () => ({}),
} satisfies Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
};
}
function appendQaGatewayTempRoot(details: string, tempRoot: string) {
@@ -222,7 +225,10 @@ export async function startQaGatewayChild(params: {
command?: QaGatewayChildCommand;
useRepoCli?: boolean;
providerBaseUrl?: string;
transport?: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
transport?: Pick<
QaTransportAdapter,
"requiredPluginIds" | "createGatewayConfig" | "stageGatewayRuntime"
>;
transportBaseUrl: string;
controlUiAllowedOrigins?: string[];
providerMode?: QaProviderMode;
@@ -278,6 +284,7 @@ export async function startQaGatewayChild(params: {
const packagedAuthConfigPath = path.join(stateDir, "qa-auth-bootstrap", "openclaw.json");
const gatewayToken = `qa-suite-${randomUUID()}`;
const transport = params.transport ?? createQaGatewayEmptyTransport();
await transport.stageGatewayRuntime?.({ tempRoot });
await seedQaAgentWorkspace({
workspaceDir,
repoRoot: params.repoRoot,
@@ -389,7 +396,6 @@ export async function startQaGatewayChild(params: {
let packagedMockAuthStaged = false;
let migrationConvergenceRestartUsed = false;
let reuseStartupLaunchState = false;
const nodeExecPath = gatewayExecutablePath ?? (await resolveQaNodeExecPath());
const cliArgsPrefix = gatewayExecutablePath
? gatewayArgsPrefix
@@ -66,4 +66,31 @@ describe("live transport QA contributions", () => {
expect.objectContaining({ scenarioIds: ["telegram-canary"] }),
);
});
it("maps the dedicated Discord driver and listing options", async () => {
const registration = listLiveTransportQaCliRegistrations().find(
(candidate) => candidate.commandName === "discord",
);
const qa = new Command();
registration?.register(qa);
await qa.parseAsync([
"node",
"openclaw",
"discord",
"--channel-driver",
"crabline",
"--list-scenarios",
]);
expect(runLiveTransportQaSuiteCommand).toHaveBeenCalledWith(
expect.objectContaining({
channelId: "discord",
options: expect.objectContaining({
channelDriver: "crabline",
listScenarios: true,
}),
}),
);
});
});
@@ -38,7 +38,10 @@ export const discordQaCliRegistration: LiveTransportQaCliRegistration =
roleDescription:
"Credential role for convex auth: maintainer or ci (default: ci in CI, maintainer otherwise)",
},
description: "Run the Discord live QA lane against a private guild bot-to-bot harness",
channelDriverHelp:
"Discord transport boundary: live (default) or Crabline local provider server",
description: "Run Discord QA through the live service or Crabline local provider server",
listScenariosHelp: "Print the selected Discord scenario ids and exit",
outputDirHelp: "Discord QA artifact directory",
scenarioHelp: "Run only the named Discord QA scenario (repeatable)",
sutAccountHelp: "Temporary Discord account id inside the QA gateway config",
@@ -1,16 +1,19 @@
import type { QaProviderModeInput } from "../../model-selection.js";
import { resolveLiveTransportQaScenarioIds } from "../shared/scenario-selection.js";
import type { QaScorecardChannelDriver } from "../../scorecard-taxonomy.js";
import { resolveTransportQaScenarioIds } from "../shared/scenario-selection.js";
export function resolveDiscordQaScenarioIds(params: {
profile?: string;
channelDriver?: QaScorecardChannelDriver;
primaryModel?: string;
providerMode?: QaProviderModeInput;
scenarioIds?: readonly string[];
}) {
return resolveLiveTransportQaScenarioIds({
return resolveTransportQaScenarioIds({
channelId: "discord",
supportsModuleFlows: true,
...params,
channelDriver: params.channelDriver ?? "live",
providerMode: params.providerMode ?? "live-frontier",
supportsModuleFlows: true,
});
}
@@ -18,6 +18,7 @@ export type LiveTransportQaCommandOptions = {
sutAccountId?: string;
credentialSource?: string;
credentialRole?: string;
channelDriver?: string;
};
type LiveTransportQaCommanderOptions = {
@@ -35,6 +36,7 @@ type LiveTransportQaCommanderOptions = {
sutAccount?: string;
credentialSource?: string;
credentialRole?: string;
channelDriver?: string;
};
export type LiveTransportQaCliRegistration = QaRunnerCliRegistration;
@@ -45,6 +47,7 @@ type LiveTransportQaCliRegistrationOptions = {
sourceDescription?: string;
roleDescription?: string;
};
channelDriverHelp?: string;
defaultProviderMode: string;
description: string;
providerModeHelp: string;
@@ -88,6 +91,7 @@ function mapCommanderOptions(opts: LiveTransportQaCommanderOptions): LiveTranspo
sutAccountId: opts.sutAccount,
credentialSource: opts.credentialSource,
credentialRole: opts.credentialRole,
channelDriver: opts.channelDriver,
};
}
@@ -132,6 +136,9 @@ function createSharedLiveTransportQaCliRegistration(
command.option("--credential-role <role>", params.credentialOptions.roleDescription);
}
}
if (params.channelDriverHelp) {
command.option("--channel-driver <live|crabline>", params.channelDriverHelp);
}
command.action(async (opts: LiveTransportQaCommanderOptions) => {
await params.run(mapCommanderOptions(opts));
});
@@ -93,6 +93,114 @@ describe("live transport suite runtime", () => {
);
});
it.each([undefined, "live"] as const)(
"keeps the Discord live driver identical when selected as %s",
async (channelDriver) => {
const selectScenarioIds = vi.fn(() => ["discord-canary"]);
await runLiveTransportQaSuiteCommand({
channelId: "discord",
defaultProviderMode: "live-frontier",
options: { channelDriver },
selectScenarioIds,
});
expect(selectScenarioIds).toHaveBeenCalledWith(
expect.objectContaining({ channelDriver: "live" }),
);
expect(runQaSuiteCommand).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
channelDriver: "live",
scenarioIds: ["discord-canary"],
}),
);
},
);
it("selects Discord Crabline without forwarding credential lease options", async () => {
const selectScenarioIds = vi.fn(() => ["discord-crabline-roundtrip"]);
await runLiveTransportQaSuiteCommand({
channelId: "discord",
defaultProviderMode: "live-frontier",
options: {
channelDriver: "crabline",
providerMode: "mock-openai",
primaryModel: "mock-openai/custom",
},
selectScenarioIds,
});
expect(selectScenarioIds).toHaveBeenCalledWith({
channelDriver: "crabline",
profile: undefined,
primaryModel: "mock-openai/custom",
providerMode: "mock-openai",
scenarioIds: undefined,
});
expect(runQaSuiteCommand).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
channelDriver: "crabline",
providerMode: "mock-openai",
primaryModel: "mock-openai/custom",
scenarioIds: ["discord-crabline-roundtrip"],
}),
);
expect(runQaSuiteCommand.mock.calls[0]?.[0]).not.toHaveProperty("credentialSource");
expect(runQaSuiteCommand.mock.calls[0]?.[0]).not.toHaveProperty("credentialRole");
});
it("uses the same Discord selection for listing and execution", async () => {
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const selectScenarioIds = vi.fn((_selection: unknown) => [
"channel-canary",
"thread-follow-up",
]);
const base = {
channelId: "discord",
defaultProviderMode: "mock-openai" as const,
selectScenarioIds,
};
try {
await runLiveTransportQaSuiteCommand({
...base,
options: { channelDriver: "crabline", listScenarios: true },
});
await runLiveTransportQaSuiteCommand({
...base,
options: { channelDriver: "crabline" },
});
} finally {
stdoutWrite.mockRestore();
}
expect(selectScenarioIds).toHaveBeenCalledTimes(2);
expect(selectScenarioIds.mock.calls[0]?.[0]).toEqual(selectScenarioIds.mock.calls[1]?.[0]);
expect(runQaSuiteCommand).toHaveBeenCalledWith(
expect.objectContaining({ scenarioIds: ["channel-canary", "thread-follow-up"] }),
);
});
it.each(["credentialSource", "credentialRole"] as const)(
"rejects Discord Crabline with %s",
async (option) => {
await expect(
runLiveTransportQaSuiteCommand({
channelId: "discord",
defaultProviderMode: "mock-openai",
options: { channelDriver: "crabline", [option]: "ci" },
selectScenarioIds: () => ["channel-canary"],
}),
).rejects.toThrow(
`do not use --${option === "credentialSource" ? "credential-source" : "credential-role"}`,
);
expect(runQaSuiteCommand).not.toHaveBeenCalled();
},
);
it("rejects shared credentials for disposable transports", async () => {
await expect(
runLiveTransportQaSuiteCommand({
@@ -3,26 +3,47 @@ import { runQaSuiteCommand } from "../../cli.runtime.js";
import type { QaProviderMode } from "../../providers/index.js";
import { defaultQaModelForMode, normalizeQaProviderMode } from "../../run-config.js";
type DedicatedLiveTransportQaCommandOptions = LiveTransportQaCommandOptions & {
channelDriver?: string;
};
type LiveTransportScenarioSelection = (params: {
channelDriver: "live" | "crabline";
profile?: string;
primaryModel: string;
providerMode: QaProviderMode;
scenarioIds?: readonly string[];
}) => string[];
function resolveDedicatedChannelDriver(value: string | undefined): "live" | "crabline" {
const normalized = value?.trim().toLowerCase() || "live";
if (normalized !== "live" && normalized !== "crabline") {
throw new Error(`channel driver must be live or crabline, got "${value}".`);
}
return normalized;
}
export async function runLiveTransportQaSuiteCommand(params: {
channelId: string;
credentialMode?: "env-only" | "shared-lease";
defaultProviderMode: QaProviderMode;
envCredentialReason?: string;
laneLabel?: string;
options: LiveTransportQaCommandOptions;
options: DedicatedLiveTransportQaCommandOptions;
selectScenarioIds: LiveTransportScenarioSelection;
}) {
const options = params.options;
const credentialSource =
options.credentialSource?.trim() || process.env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim();
if (params.credentialMode === "env-only") {
const channelDriver = resolveDedicatedChannelDriver(options.channelDriver);
if (channelDriver === "crabline") {
if (options.credentialSource?.trim()) {
throw new Error("QA Lab Crabline channel drivers do not use --credential-source.");
}
if (options.credentialRole?.trim()) {
throw new Error("QA Lab Crabline channel drivers do not use --credential-role.");
}
} else if (params.credentialMode === "env-only") {
const laneLabel = params.laneLabel ?? params.channelId;
if (credentialSource && credentialSource.toLowerCase() !== "env") {
throw new Error(
@@ -40,11 +61,18 @@ export async function runLiveTransportQaSuiteCommand(params: {
: normalizeQaProviderMode(options.providerMode);
const primaryModel = options.primaryModel?.trim() || defaultQaModelForMode(providerMode);
const selectedScenarioIds = params.selectScenarioIds({
channelDriver,
profile: options.profile,
primaryModel,
providerMode,
scenarioIds: options.scenarioIds,
});
if (options.listScenarios) {
for (const scenarioId of selectedScenarioIds) {
process.stdout.write(`${scenarioId}\n`);
}
return undefined;
}
return runQaSuiteCommand({
repoRoot: options.repoRoot,
outputDir: options.outputDir,
@@ -54,13 +82,13 @@ export async function runLiveTransportQaSuiteCommand(params: {
fastMode: options.fastMode,
allowFailures: options.allowFailures,
failFast: options.failFast,
channelDriver: "live",
channelDriver,
channel: params.channelId,
concurrency: 1,
scenarioIds: selectedScenarioIds,
sutAccountId: options.sutAccountId,
...(options.credentialFile ? { credentialFile: options.credentialFile } : {}),
...(params.credentialMode === "env-only"
...(channelDriver === "crabline" || params.credentialMode === "env-only"
? {}
: {
credentialSource,
@@ -140,6 +140,7 @@ describe("live transport QA scenario selection", () => {
{ channelId: "matrix", scenarioId: "thread-follow-up" },
{ channelId: "telegram", scenarioId: "channel-canary" },
{ channelId: "telegram", scenarioId: "channel-message-flows" },
{ channelId: "discord", scenarioId: "channel-canary" },
] as const)(
"keeps $scenarioId eligible through both $channelId drivers",
({ channelId, scenarioId }) => {
@@ -185,4 +186,22 @@ describe("live transport QA scenario selection", () => {
"selected QA scenario(s) do not match the current QA lane: channel-canary (channel=qa-channel|telegram|buzz|msteams)",
);
});
it("defaults dedicated Discord selection to live and excludes live-only modules from Crabline", () => {
const implicitLive = resolveDiscordQaScenarioIds(MOCK_LANE);
const explicitLive = resolveDiscordQaScenarioIds({ ...MOCK_LANE, channelDriver: "live" });
const crabline = resolveDiscordQaScenarioIds({ ...MOCK_LANE, channelDriver: "crabline" });
expect(explicitLive).toEqual(implicitLive);
expect(implicitLive).toContain("discord-canary");
expect(crabline).toContain("discord-crabline-roundtrip");
expect(crabline).not.toContain("discord-canary");
expect(() =>
resolveDiscordQaScenarioIds({
...MOCK_LANE,
channelDriver: "crabline",
scenarioIds: ["discord-canary"],
}),
).toThrow("channelDriver=live");
});
});
@@ -41,19 +41,29 @@ export function resolveCatalogLiveTransportQaScenarioIds(params: {
return selectedScenarios.map((scenario) => scenario.id);
}
export function resolveLiveTransportQaScenarioIds(params: {
export function resolveTransportQaScenarioIds(params: {
channelId: string;
channelDriver: QaScorecardChannelDriver;
profile?: string;
primaryModel?: string;
providerMode: QaProviderModeInput;
scenarioIds?: readonly string[];
supportsModuleFlows?: boolean;
}) {
if (!params.profile?.trim() && params.channelDriver === "crabline") {
return resolveCatalogLiveTransportQaScenarioIds({
channelId: params.channelId,
channelDriver: params.channelDriver,
primaryModel: params.primaryModel,
providerMode: params.providerMode,
scenarioIds: params.scenarioIds,
});
}
return resolveQaProfileScenarios({
profile: params.profile?.trim() || "release",
providerMode: params.providerMode,
primaryModel: params.primaryModel,
channelDriver: "live",
channelDriver: params.channelDriver,
channel: params.channelId,
executionKind: "flow",
requireDeclaredChannel: true,
@@ -62,6 +72,12 @@ export function resolveLiveTransportQaScenarioIds(params: {
}).scenarios.map((scenario) => scenario.id);
}
export function resolveLiveTransportQaScenarioIds(
params: Omit<Parameters<typeof resolveTransportQaScenarioIds>[0], "channelDriver">,
) {
return resolveTransportQaScenarioIds({ ...params, channelDriver: "live" });
}
export function listLiveTransportQaScenarios(params: {
channelId: string;
primaryModel?: string;
+1
View File
@@ -315,6 +315,7 @@ export type QaTransportAdapter = Omit<
timeoutMs?: number,
intervalMs?: number,
) => Promise<T>;
stageGatewayRuntime?: (params: { tempRoot: string }) => Promise<void>;
};
export abstract class QaStateBackedTransportAdapter implements QaTransportAdapter {
+65
View File
@@ -624,6 +624,71 @@ describe("qa run config", () => {
);
});
it("selects only scenarios that declare an explicit external channel", () => {
const catalog = readQaScenarioPack();
const scorecardReport = readQaScorecardTaxonomyReport(catalog.scenarios);
const selection = normalizeQaRunSelection(
{
profile: "all",
channel: "discord",
channelDriver: "crabline",
providerMode: "mock-openai",
},
catalog.scenarios,
scorecardReport.profiles,
);
const plan = resolveQaLabRunPlan({
selection,
scenarios: catalog.scenarios,
scorecardReport,
defaultChannel: "discord",
supportsChannel: (channel) => channel === "discord",
});
expect(plan.status).toBe("ready");
expect(plan.selectedScenarios.map((scenario) => scenario.id)).toEqual([
"discord-crabline-roundtrip",
]);
expect(plan.exclusions).toEqual(
expect.arrayContaining([
expect.objectContaining({
scenarioId: "compaction-retry-mutating-tool",
reasons: ["does not declare channel discord"],
}),
]),
);
});
it("rejects an explicit scenario that does not declare the external channel", () => {
const catalog = readQaScenarioPack();
const scorecardReport = readQaScorecardTaxonomyReport(catalog.scenarios);
const selection = normalizeQaRunSelection(
{
profile: "all",
channel: "discord",
channelDriver: "crabline",
providerMode: "mock-openai",
scenarioIds: ["compaction-retry-mutating-tool"],
},
catalog.scenarios,
scorecardReport.profiles,
);
const plan = resolveQaLabRunPlan({
selection,
scenarios: catalog.scenarios,
scorecardReport,
defaultChannel: "discord",
supportsChannel: (channel) => channel === "discord",
});
expect(plan.status).toBe("invalid");
expect(plan.errors).toContain(
"Explicit QA scenario selection is not runnable: compaction-retry-mutating-tool (does not declare channel discord).",
);
});
it("fails closed when an explicit scenario conflicts with execution.channel", () => {
const catalog = readQaScenarioPack();
const scorecardReport = readQaScorecardTaxonomyReport(catalog.scenarios);
+21 -1
View File
@@ -16,6 +16,7 @@ import {
import {
resolveQaRunProfileExecutionSelection,
resolveQaRunProfileMembership,
scenarioDeclaresQaChannel,
} from "./profile-planning.js";
import {
DEFAULT_QA_LIVE_PROVIDER_MODE,
@@ -360,8 +361,27 @@ export function resolveQaLabRunPlan(params: {
const scenario = scenarioById.get(scenarioId);
return scenario ? [scenario] : [];
});
const requiresDeclaredChannel = Boolean(
selection.channel &&
selection.channel !== "qa-channel" &&
selection.channelDriver !== "qa-channel",
);
const channelScopedScenarios = requiresDeclaredChannel
? laneScenarios.filter((scenario) => scenarioDeclaresQaChannel(scenario, selection.channel!))
: laneScenarios;
if (requiresDeclaredChannel) {
exclusions.push(
...laneScenarios
.filter((scenario) => !channelScopedScenarios.includes(scenario))
.map((scenario) => ({
scenarioId: scenario.id,
executionKind: scenario.execution.kind,
reasons: [`does not declare channel ${selection.channel}`],
})),
);
}
const profileExecution = resolveQaRunProfileExecutionSelection({
scenarios: laneScenarios,
scenarios: channelScopedScenarios,
providerMode: selection.providerMode,
primaryModel: selection.primaryModel,
channelDriver: selection.channelDriver,
+2 -2
View File
@@ -990,8 +990,8 @@ describe("qa suite planning helpers", () => {
channel: "telegram",
}).map((scenario) => scenario.id);
expect(selectForDriver("crabline")).toEqual(["generic", "telegram"]);
expect(selectForDriver("live")).toEqual(["generic", "live-only", "telegram"]);
expect(selectForDriver("crabline")).toEqual(["telegram"]);
expect(selectForDriver("live")).toEqual(["live-only", "telegram"]);
});
it("rejects explicitly requested scenarios that do not match the current lane", () => {
+1
View File
@@ -7,6 +7,7 @@ import { createQaArtifactRunId } from "./artifact-run-id.js";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
import { splitQaModelRef as splitModelRef, type QaProviderMode } from "./model-selection.js";
import { scenarioDeclaresQaChannel } from "./profile-planning.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import {
describeQaProviderLaneMismatches,
@@ -8,6 +8,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
@@ -0,0 +1,78 @@
title: Discord Crabline real-plugin roundtrip
scenario:
id: discord-crabline-roundtrip
surface: channels
coverage:
primary:
- discord.configured-and-runtime-routing
secondary:
- discord.mention-gating
- discord.native-slash-command-registration
objective: Verify a provider-native Discord event traverses the real bundled plugin and produces one provider REST reply.
successCriteria:
- The real Discord plugin completes its Gateway handshake against Crabline.
- One mentioned guild message produces one visible reply containing the exact marker.
- The reply stays scoped to the originating Discord channel.
docsRefs:
- docs/concepts/qa-e2e-automation.md
- docs/channels/discord.md
codeRefs:
- extensions/discord/src/monitor/provider.ts
- extensions/qa-lab/src/crabline-transport.ts
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: crabline
conversationId: discord-crabline-primary
threadId: discord-crabline-thread
expectedMarker: DISCORD-CRABLINE-ROUNDTRIP-OK
transportPolicy:
requireGroupMention: true
timeoutMs: 60000
suiteIsolation: isolated
flow:
steps:
- name: completes Discord Gateway readiness
actions:
- call: waitForGatewayHealthy
args: [{ ref: env }, 60000]
- call: waitForTransportReady
args: [{ ref: env }, 60000]
- resetTransport: true
- name: injects an authenticated provider-native Discord event
actions:
- sendInbound:
conversation:
id: { ref: config.conversationId }
kind: group
senderId: driver
senderName: QA Driver
threadId: { ref: config.threadId }
text:
expr: "`@openclaw reply exactly: ${config.expectedMarker}`"
saveAs: inbound
- name: observes the real Discord plugin outbound REST request
actions:
- waitForOutbound:
conversation:
id: { ref: config.conversationId }
kind: group
threadId: { ref: config.threadId }
textIncludes: { ref: config.expectedMarker }
timeoutMs:
expr: liveTurnTimeoutMs(env, 60000)
saveAs: outbound
- set: matchingOutbound
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && String(message.text ?? '').includes(config.expectedMarker))"
- assert:
expr: matchingOutbound.length === 1
message:
expr: "`expected one Discord Crabline reply, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: config.conversationId })}`"
- assert:
expr: Boolean(inbound.id) && Boolean(outbound.id)
message: Discord Crabline roundtrip did not preserve provider message identity
detailsExpr: outbound.text
@@ -8,6 +8,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 8000
retryCount: 1
suiteIsolation: isolated
@@ -7,6 +7,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
@@ -8,6 +8,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 75000
retryCount: 1
suiteIsolation: isolated
@@ -9,6 +9,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 45000
retryCount: 1
suiteIsolation: isolated
@@ -7,6 +7,8 @@ scenario:
execution:
kind: flow
channel: discord
config:
requiredChannelDriver: live
timeoutMs: 60000
retryCount: 1
suiteIsolation: isolated