重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,4 @@
export function collectString(value: string, previous: string[]) {
const trimmed = value.trim();
return trimmed ? [...previous, trimmed] : previous;
}

View file

@ -0,0 +1,16 @@
import path from "node:path";
export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: string) {
if (!outputDir) {
return undefined;
}
if (path.isAbsolute(outputDir)) {
throw new Error("--output-dir must be a relative path inside the repo root.");
}
const resolved = path.resolve(repoRoot, outputDir);
const relative = path.relative(repoRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("--output-dir must stay within the repo root.");
}
return resolved;
}

View file

@ -0,0 +1,135 @@
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const runMatrixQaLive = vi.hoisted(() => vi.fn());
const closeGlobalDispatcher = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./runners/contract/runtime.js", () => ({
runMatrixQaLive,
}));
vi.mock("undici", () => ({
getGlobalDispatcher: () => ({
close: closeGlobalDispatcher,
}),
}));
import { runQaMatrixCommand } from "./cli.runtime.js";
const tmpDirs: string[] = [];
describe("matrix qa cli runtime", () => {
const originalRunNodeOutputLog = process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
afterEach(async () => {
vi.clearAllMocks();
if (originalRunNodeOutputLog === undefined) {
delete process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
} else {
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = originalRunNodeOutputLog;
}
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("rejects non-env credential sources for the disposable Matrix lane", async () => {
await expect(
runQaMatrixCommand({
credentialSource: "convex",
}),
).rejects.toThrow("Matrix QA currently supports only --credential-source env");
});
it("passes through default env credential source options", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
});
const originalStdoutWrite = process.stdout.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot,
outputDir: path.join(repoRoot, ".artifacts/qa-e2e/matrix"),
providerMode: "mock-openai",
credentialSource: "env",
}),
);
expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1);
});
it("reuses a run-node output log instead of installing a nested tee", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputPath = path.join(repoRoot, "run-node-output.log");
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = outputPath;
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
});
const originalStdoutWrite = process.stdout.write;
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledOnce();
await expect(readFile(outputPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("preserves the Matrix QA failure when output log cleanup also fails", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "matrix");
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
runMatrixQaLive.mockRejectedValue(new Error("scenario failed"));
const stderrChunks: string[] = [];
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Buffer) => {
stderrChunks.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
await expect(
runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
).rejects.toThrow("scenario failed");
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(stderrChunks.join("")).toContain("Matrix QA output log error");
});
});

View file

@ -0,0 +1,91 @@
import { runMatrixQaLive } from "./runners/contract/runtime.js";
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
startLiveTransportQaOutputTee,
} from "./shared/live-transport-cli.runtime.js";
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
async function closeMatrixQaCommandFetchHandles() {
try {
const { getGlobalDispatcher } = await import("undici");
const dispatcher = getGlobalDispatcher() as {
close?: () => Promise<void> | void;
};
await dispatcher.close?.();
} catch {
// Best-effort cleanup for short-lived QA commands. The command result and
// artifacts are already written; stale fetch keep-alive handles should not
// turn a green run into a failure.
}
}
function formatMatrixQaOutputTeeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "unknown error";
}
async function createMatrixQaCommandOutputTee(outputDir: string) {
const inheritedOutputPath = process.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
if (inheritedOutputPath) {
return {
outputPath: inheritedOutputPath,
async stop() {},
};
}
return await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
}
export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
const runOptions = resolveLiveTransportQaRunOptions(opts);
const credentialSource = runOptions.credentialSource?.toLowerCase();
if (credentialSource && credentialSource !== "env") {
throw new Error(
"Matrix QA currently supports only --credential-source env (disposable local harness).",
);
}
const outputTee = await createMatrixQaCommandOutputTee(runOptions.outputDir);
let primaryError: unknown;
let outputTeeError: unknown;
try {
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
const result = await runMatrixQaLive(runOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
summary: result.summaryPath,
"observed events": result.observedEventsPath,
});
} catch (error) {
primaryError = error;
} finally {
try {
await outputTee.stop();
} catch (error) {
outputTeeError = error;
}
await closeMatrixQaCommandFetchHandles();
}
if (primaryError) {
if (outputTeeError) {
process.stderr.write(
`Matrix QA output log error: ${formatMatrixQaOutputTeeError(outputTeeError)}\n`,
);
}
throw primaryError;
}
if (outputTeeError) {
throw outputTeeError;
}
}

View file

@ -0,0 +1,99 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { runQaMatrixCommand } = vi.hoisted(() => ({
runQaMatrixCommand: vi.fn(),
}));
vi.mock("./cli.runtime.js", () => ({
runQaMatrixCommand,
}));
import { matrixQaCliRegistration } from "./cli.js";
function mockProcessWrite(
_chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
callback?: (err?: Error | null) => void,
) {
if (typeof encodingOrCallback === "function") {
encodingOrCallback();
} else {
callback?.();
}
return true;
}
describe("matrix qa cli registration", () => {
const originalDisableForceExit = process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
runQaMatrixCommand.mockReset();
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${String(code)})`);
});
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(mockProcessWrite);
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(mockProcessWrite);
});
afterEach(() => {
if (originalDisableForceExit === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
} else {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = originalDisableForceExit;
}
exitSpy.mockRestore();
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it("keeps disposable Matrix lane flags focused", () => {
const qa = new Command();
matrixQaCliRegistration.register(qa);
const matrix = qa.commands.find((command) => command.name() === "matrix");
const optionNames = matrix?.options.map((option) => option.long) ?? [];
expect(optionNames).toEqual(
expect.arrayContaining([
"--repo-root",
"--output-dir",
"--provider-mode",
"--model",
"--alt-model",
"--scenario",
"--fast",
"--sut-account",
]),
);
expect(optionNames).not.toContain("--credential-source");
expect(optionNames).not.toContain("--credential-role");
});
it("exits with failure after Matrix artifacts are written for a failed run", async () => {
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("Matrix QA failed.\nreport: /tmp/report.md"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("process.exit(1)");
expect(runQaMatrixCommand).toHaveBeenCalledOnce();
expect(stderrSpy).toHaveBeenCalledWith("Matrix QA failed.\nreport: /tmp/report.md\n");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("can disable the forced exit for direct test harnesses", async () => {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = "1";
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("scenario failed"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("scenario failed");
expect(exitSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,68 @@
import type { Command } from "commander";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createLazyCliRuntimeLoader,
createLiveTransportQaCliRegistration,
type LiveTransportQaCliRegistration,
type LiveTransportQaCommandOptions,
} from "./shared/live-transport-cli.js";
type MatrixQaCliRuntime = typeof import("./cli.runtime.js");
const DISABLE_MATRIX_QA_FORCE_EXIT_ENV = "OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT";
const loadMatrixQaCliRuntime = createLazyCliRuntimeLoader<MatrixQaCliRuntime>(
() => import("./cli.runtime.js"),
);
async function flushProcessStream(stream: NodeJS.WriteStream) {
if (stream.destroyed || !stream.writable) {
return;
}
await new Promise<void>((resolve) => {
try {
stream.write("", () => resolve());
} catch {
resolve();
}
});
}
async function exitMatrixQaCommand(code: number): Promise<never> {
// Matrix crypto native handles can outlive the QA run even after every
// client/gateway/harness has been stopped. This command is single-shot, so
// artifact completion should terminate deterministically on both pass and fail.
await Promise.all([flushProcessStream(process.stdout), flushProcessStream(process.stderr)]);
process.exit(code);
}
async function runQaMatrix(opts: LiveTransportQaCommandOptions) {
const runtime = await loadMatrixQaCliRuntime();
if (process.env[DISABLE_MATRIX_QA_FORCE_EXIT_ENV] === "1") {
await runtime.runQaMatrixCommand(opts);
return;
}
try {
await runtime.runQaMatrixCommand(opts);
await exitMatrixQaCommand(0);
} catch (error) {
process.stderr.write(`${formatErrorMessage(error)}\n`);
await exitMatrixQaCommand(1);
}
}
export const matrixQaCliRegistration: LiveTransportQaCliRegistration =
createLiveTransportQaCliRegistration({
commandName: "matrix",
description: "Run the Docker-backed Matrix live QA lane against a disposable homeserver",
outputDirHelp: "Matrix QA artifact directory",
scenarioHelp: "Run only the named Matrix QA scenario (repeatable)",
sutAccountHelp: "Temporary Matrix account id inside the QA gateway config",
run: runQaMatrix,
});
export const qaRunnerCliRegistrations = [matrixQaCliRegistration] as const;
export function registerMatrixQaCli(qa: Command) {
matrixQaCliRegistration.register(qa);
}

View file

@ -0,0 +1,274 @@
import { createServer } from "node:net";
import { runExec } from "openclaw/plugin-sdk/process-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
export type RunCommand = (
command: string,
args: string[],
cwd: string,
) => Promise<{ stdout: string; stderr: string }>;
export type FetchLike = (input: string) => Promise<{ ok: boolean }>;
export async function fetchHealthUrl(url: string): Promise<{ ok: boolean }> {
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
signal: AbortSignal.timeout(2_000),
},
policy: { allowPrivateNetwork: true },
auditContext: "qa-matrix-docker-health-check",
});
try {
return { ok: response.ok };
} finally {
await release();
}
}
export function describeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return JSON.stringify(error);
}
async function isPortFree(port: number) {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort() {
return await new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("failed to find free port"));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
export async function resolveHostPort(preferredPort: number, pinned: boolean) {
if (pinned || (await isPortFree(preferredPort))) {
return preferredPort;
}
return await findFreePort();
}
function trimCommandOutput(output: string) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
return lines.length <= 120 ? trimmed : lines.slice(-120).join("\n");
}
export async function execCommand(command: string, args: string[], cwd: string) {
try {
return await runExec(command, args, { cwd, maxBuffer: 10 * 1024 * 1024 });
} catch (error) {
const failedProcess = error as Error & { stdout?: string; stderr?: string };
const renderedStdout = trimCommandOutput(failedProcess.stdout ?? "");
const renderedStderr = trimCommandOutput(failedProcess.stderr ?? "");
throw new Error(
[
`Command failed: ${[command, ...args].join(" ")}`,
renderedStderr ? `stderr:\n${renderedStderr}` : "",
renderedStdout ? `stdout:\n${renderedStdout}` : "",
]
.filter(Boolean)
.join("\n\n"),
{ cause: error },
);
}
}
export async function waitForHealth(
url: string,
deps: {
label?: string;
composeFile?: string;
fetchImpl: FetchLike;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
},
) {
const timeoutMs = deps.timeoutMs ?? 360_000;
const pollMs = deps.pollMs ?? 1_000;
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
const response = await deps.fetchImpl(url);
if (response.ok) {
return;
}
lastError = new Error(`Health check returned non-OK for ${url}`);
} catch (error) {
lastError = error;
}
await deps.sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
const service = deps.label ?? url;
const lines = [
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
lastError ? `Last error: ${describeError(lastError)}` : "",
`Hint: check container logs with \`docker compose -f ${deps.composeFile ?? "<compose-file>"} logs\` and verify the port is not already in use.`,
];
throw new Error(lines.filter(Boolean).join("\n"));
}
async function isHealthy(url: string, fetchImpl: FetchLike) {
try {
const response = await fetchImpl(url);
return response.ok;
} catch {
return false;
}
}
function normalizeDockerServiceStatus(row?: { Health?: string; State?: string }) {
const health = row?.Health?.trim();
if (health) {
return health;
}
const state = row?.State?.trim();
if (state) {
return state;
}
return "unknown";
}
function parseDockerComposePsRows(stdout: string) {
const trimmed = stdout.trim();
if (!trimmed) {
return [] as Array<{ Health?: string; State?: string }>;
}
try {
const parsed = JSON.parse(trimmed) as
| Array<{ Health?: string; State?: string }>
| { Health?: string; State?: string };
if (Array.isArray(parsed)) {
return parsed;
}
return [parsed];
} catch {
return trimmed
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as { Health?: string; State?: string });
}
}
export async function waitForDockerServiceHealth(
service: string,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
sleepImpl: (ms: number) => Promise<unknown>,
timeoutMs = 360_000,
pollMs = 1_000,
) {
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastStatus = "unknown";
while (Date.now() < deadline) {
try {
const { stdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "--format", "json", service],
repoRoot,
);
const rows = parseDockerComposePsRows(stdout);
const row = rows[0];
lastStatus = normalizeDockerServiceStatus(row);
if (lastStatus === "healthy" || lastStatus === "running") {
return;
}
} catch (error) {
lastStatus = describeError(error);
}
await sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
throw new Error(
[
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
`Last status: ${lastStatus}`,
`Hint: check container logs with \`docker compose -f ${composeFile} logs ${service}\`.`,
].join("\n"),
);
}
export async function resolveComposeServiceUrl(
service: string,
port: number,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
fetchImpl?: FetchLike,
) {
const { stdout: containerStdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "-q", service],
repoRoot,
);
const containerId = containerStdout.trim();
if (!containerId) {
return null;
}
const { stdout: ipStdout } = await runCommand(
"docker",
[
"inspect",
"--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
containerId,
],
repoRoot,
);
const ip = ipStdout.trim();
if (!ip) {
return null;
}
const baseUrl = `http://${ip}:${port}/`;
if (!fetchImpl) {
return baseUrl;
}
return (await isHealthy(`${baseUrl}healthz`, fetchImpl)) ? baseUrl : null;
}
export const __testing = {
fetchHealthUrl,
normalizeDockerServiceStatus,
};

View file

@ -0,0 +1,100 @@
export type QaReportCheck = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
};
export type QaReportScenario = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
steps?: QaReportCheck[];
};
function pushDetailsBlock(lines: string[], label: string, details: string, indent = "") {
if (!details.includes("\n")) {
lines.push(`${indent}- ${label}: ${details}`);
return;
}
lines.push(`${indent}- ${label}:`);
lines.push("", "```text", details, "```");
}
export function renderQaMarkdownReport(params: {
title: string;
startedAt: Date;
finishedAt: Date;
checks?: QaReportCheck[];
scenarios?: QaReportScenario[];
timeline?: string[];
notes?: string[];
}) {
const checks = params.checks ?? [];
const scenarios = params.scenarios ?? [];
const passCount =
checks.filter((check) => check.status === "pass").length +
scenarios.filter((scenario) => scenario.status === "pass").length;
const failCount =
checks.filter((check) => check.status === "fail").length +
scenarios.filter((scenario) => scenario.status === "fail").length;
const lines = [
`# ${params.title}`,
"",
`- Started: ${params.startedAt.toISOString()}`,
`- Finished: ${params.finishedAt.toISOString()}`,
`- Duration ms: ${params.finishedAt.getTime() - params.startedAt.getTime()}`,
`- Passed: ${passCount}`,
`- Failed: ${failCount}`,
"",
];
if (checks.length > 0) {
lines.push("## Checks", "");
for (const check of checks) {
lines.push(`- [${check.status === "pass" ? "x" : " "}] ${check.name}`);
if (check.details) {
pushDetailsBlock(lines, "Details", check.details, " ");
}
}
}
if (scenarios.length > 0) {
lines.push("", "## Scenarios", "");
for (const scenario of scenarios) {
lines.push(`### ${scenario.name}`);
lines.push("");
lines.push(`- Status: ${scenario.status}`);
if (scenario.details) {
pushDetailsBlock(lines, "Details", scenario.details);
}
if (scenario.steps?.length) {
lines.push("- Steps:");
for (const step of scenario.steps) {
lines.push(` - [${step.status === "pass" ? "x" : " "}] ${step.name}`);
if (step.details) {
pushDetailsBlock(lines, "Details", step.details, " ");
}
}
}
lines.push("");
}
}
if (params.timeline && params.timeline.length > 0) {
lines.push("## Timeline", "");
for (const item of params.timeline) {
lines.push(`- ${item}`);
}
}
if (params.notes && params.notes.length > 0) {
lines.push("", "## Notes", "");
for (const note of params.notes) {
lines.push(`- ${note}`);
}
}
lines.push("");
return lines.join("\n");
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeQaProviderMode } from "./run-config.js";
describe("matrix qa run config", () => {
it("defaults to live-frontier when provider mode is omitted", () => {
expect(normalizeQaProviderMode(undefined)).toBe("live-frontier");
expect(normalizeQaProviderMode("")).toBe("live-frontier");
});
it("keeps legacy live-openai as an alias for live-frontier", () => {
expect(normalizeQaProviderMode("live-openai")).toBe("live-frontier");
});
it("rejects unknown provider modes", () => {
expect(() => normalizeQaProviderMode("mystery-mode")).toThrow(
"unknown QA provider mode: mystery-mode",
);
});
});

View file

@ -0,0 +1,16 @@
export type QaProviderMode = "mock-openai" | "live-frontier";
export type QaProviderModeInput = QaProviderMode | "live-openai";
export function normalizeQaProviderMode(input: unknown): QaProviderMode {
if (input === undefined || input === null || input === "") {
return "live-frontier";
}
if (input === "mock-openai") {
return "mock-openai";
}
if (input === "live-frontier" || input === "live-openai") {
return "live-frontier";
}
const details = typeof input === "string" ? `: ${input}` : "";
throw new Error(`unknown QA provider mode${details}`);
}

View file

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadQaRuntimeModule = vi.hoisted(() => vi.fn());
const defaultQaRuntimeModelForMode = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/qa-runner-runtime", () => ({
loadQaRuntimeModule,
}));
describe("matrix qa model selection", () => {
beforeEach(() => {
defaultQaRuntimeModelForMode
.mockReset()
.mockImplementation((mode, options) =>
options?.alternate ? `${mode}:alt` : `${mode}:primary`,
);
loadQaRuntimeModule.mockReset().mockReturnValue({
defaultQaRuntimeModelForMode,
});
});
it("delegates default model selection through qa-lab runtime defaults", async () => {
const { resolveMatrixQaModels } = await import("./model-selection.js");
expect(resolveMatrixQaModels({ providerMode: "live-openai" })).toEqual({
providerMode: "live-frontier",
primaryModel: "live-frontier:primary",
alternateModel: "live-frontier:alt",
});
expect(defaultQaRuntimeModelForMode).toHaveBeenNthCalledWith(1, "live-frontier");
expect(defaultQaRuntimeModelForMode).toHaveBeenNthCalledWith(2, "live-frontier", {
alternate: true,
});
});
it("preserves explicit model overrides", async () => {
const { resolveMatrixQaModels } = await import("./model-selection.js");
expect(
resolveMatrixQaModels({
providerMode: "mock-openai",
primaryModel: "custom-primary",
alternateModel: "custom-alt",
}),
).toEqual({
providerMode: "mock-openai",
primaryModel: "custom-primary",
alternateModel: "custom-alt",
});
expect(loadQaRuntimeModule).not.toHaveBeenCalled();
expect(defaultQaRuntimeModelForMode).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,33 @@
import { loadQaRuntimeModule } from "openclaw/plugin-sdk/qa-runner-runtime";
import { normalizeQaProviderMode, type QaProviderModeInput } from "../../run-config.js";
export type ResolvedMatrixQaModels = {
providerMode: ReturnType<typeof normalizeQaProviderMode>;
primaryModel: string;
alternateModel: string;
};
export function resolveMatrixQaModels(params: {
providerMode?: QaProviderModeInput;
primaryModel?: string;
alternateModel?: string;
}): ResolvedMatrixQaModels {
const providerMode = normalizeQaProviderMode(params.providerMode ?? "live-frontier");
const primaryModel = params.primaryModel?.trim();
const alternateModel = params.alternateModel?.trim();
if (primaryModel && alternateModel) {
return {
providerMode,
primaryModel,
alternateModel,
};
}
const qaRuntime = loadQaRuntimeModule();
return {
providerMode,
primaryModel: primaryModel || qaRuntime.defaultQaRuntimeModelForMode(providerMode),
alternateModel:
alternateModel || qaRuntime.defaultQaRuntimeModelForMode(providerMode, { alternate: true }),
};
}

View file

@ -0,0 +1,566 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderQaMarkdownReport } from "../../report.js";
import { __testing as liveTesting } from "./runtime.js";
afterEach(() => {
vi.useRealTimers();
});
describe("matrix live qa runtime", () => {
it("injects a temporary Matrix account into the QA gateway config", () => {
const baseCfg: OpenClawConfig = {
plugins: {
allow: ["memory-core", "qa-channel"],
entries: {
"memory-core": { enabled: true },
"qa-channel": { enabled: true },
},
},
};
const next = liveTesting.buildMatrixQaConfig(baseCfg, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "syt_sut",
sutAccountId: "sut",
sutDeviceId: "DEVICE123",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
});
expect(next.plugins?.allow).toContain("matrix");
expect(next.plugins?.entries?.matrix).toEqual({ enabled: true });
expect(next.channels?.matrix).toEqual({
enabled: true,
defaultAccount: "sut",
accounts: {
sut: {
accessToken: "syt_sut",
deviceId: "DEVICE123",
dm: { enabled: false },
enabled: true,
encryption: false,
groupAllowFrom: ["@driver:matrix-qa.test"],
groupPolicy: "allowlist",
groups: {
"!room:matrix-qa.test": {
enabled: true,
requireMention: true,
},
},
homeserver: "http://127.0.0.1:28008/",
network: {
dangerouslyAllowPrivateNetwork: true,
},
replyToMode: "off",
threadReplies: "inbound",
userId: "@sut:matrix-qa.test",
},
},
});
});
it("derives Matrix DM + multi-room config from provisioned topology", () => {
const next = liveTesting.buildMatrixQaConfig(
{},
{
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "syt_sut",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room-a:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA A",
requireMention: true,
roomId: "!room-a:matrix-qa.test",
},
{
key: "secondary",
kind: "group",
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "Matrix QA B",
requireMention: false,
roomId: "!room-b:matrix-qa.test",
},
{
key: "sut-dm",
kind: "dm",
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "Matrix QA DM",
requireMention: false,
roomId: "!dm:matrix-qa.test",
},
],
},
},
);
expect(next.channels?.matrix?.accounts?.sut?.dm).toEqual({
allowFrom: ["@driver:matrix-qa.test"],
enabled: true,
policy: "allowlist",
});
expect(next.channels?.matrix?.accounts?.sut?.groups).toEqual({
"!room-a:matrix-qa.test": {
enabled: true,
requireMention: true,
},
"!room-b:matrix-qa.test": {
enabled: true,
requireMention: false,
},
});
});
it("records default and per-scenario Matrix config snapshots in the summary", () => {
expect(
liveTesting.buildMatrixQaSummary({
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
config: {
default: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
}),
scenarios: [
{
id: "matrix-room-thread-reply-override",
title: "Matrix threadReplies always keeps room replies threaded",
config: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
threadReplies: "always",
},
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
}),
},
],
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
composeFile: "/tmp/docker-compose.yml",
dmRoomIds: [],
image: "ghcr.io/matrix-construct/tuwunel:v1.5.1",
roomId: "!room:matrix-qa.test",
roomIds: ["!room:matrix-qa.test"],
serverName: "matrix-qa.test",
},
observedEventCount: 0,
scenarios: [],
startedAt: "2026-04-10T10:00:00.000Z",
sutAccountId: "sut",
timings: {
artifactWriteMs: 5,
canaryMs: 40,
harnessBootMs: 100,
initialGatewayBootMs: 200,
provisioningMs: 300,
scenarioGatewayBootMs: 50,
scenarioRestartGatewayMs: 60,
scenarioTransportInterruptMs: 70,
scenarios: [],
totalMs: 825,
},
userIds: {
driver: "@driver:matrix-qa.test",
observer: "@observer:matrix-qa.test",
sut: "@sut:matrix-qa.test",
},
}).config,
).toMatchObject({
default: {
replyToMode: "off",
threadReplies: "inbound",
},
scenarios: [
{
id: "matrix-room-thread-reply-override",
config: {
threadReplies: "always",
},
},
],
});
});
it("preserves negative-scenario artifacts in the Matrix summary", () => {
expect(
liveTesting.buildMatrixQaSummary({
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
config: {
default: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [],
},
}),
scenarios: [],
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
composeFile: "/tmp/docker-compose.yml",
dmRoomIds: [],
image: "ghcr.io/matrix-construct/tuwunel:v1.5.1",
roomId: "!room:matrix-qa.test",
roomIds: ["!room:matrix-qa.test"],
serverName: "matrix-qa.test",
},
observedEventCount: 4,
scenarios: [
{
id: "matrix-mention-gating",
title: "Matrix room message without mention does not trigger",
status: "pass",
details: "no reply",
artifacts: {
actorUserId: "@driver:matrix-qa.test",
driverEventId: "$driver",
expectedNoReplyWindowMs: 8_000,
token: "MATRIX_QA_NOMENTION_TOKEN",
triggerBody: "reply with only this exact marker: MATRIX_QA_NOMENTION_TOKEN",
},
},
],
startedAt: "2026-04-10T10:00:00.000Z",
sutAccountId: "sut",
timings: {
artifactWriteMs: 5,
canaryMs: 40,
harnessBootMs: 100,
initialGatewayBootMs: 200,
provisioningMs: 300,
scenarioGatewayBootMs: 50,
scenarioRestartGatewayMs: 60,
scenarioTransportInterruptMs: 70,
scenarios: [
{
durationMs: 80,
gatewayBootMs: 0,
gatewayRestartMs: 0,
id: "matrix-mention-gating",
title: "Matrix room message without mention does not trigger",
transportInterruptMs: 0,
},
],
totalMs: 905,
},
userIds: {
driver: "@driver:matrix-qa.test",
observer: "@observer:matrix-qa.test",
sut: "@sut:matrix-qa.test",
},
}),
).toMatchObject({
counts: {
total: 2,
passed: 2,
failed: 0,
},
scenarios: [
{
id: "matrix-mention-gating",
artifacts: {
actorUserId: "@driver:matrix-qa.test",
expectedNoReplyWindowMs: 8_000,
triggerBody: "reply with only this exact marker: MATRIX_QA_NOMENTION_TOKEN",
},
},
],
timings: {
totalMs: 905,
},
});
});
it("keeps failing Matrix scenario details and timings complete in summary + report output", () => {
const summary = liveTesting.buildMatrixQaSummary({
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
config: {
default: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [],
},
}),
scenarios: [],
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
composeFile: "/tmp/docker-compose.yml",
dmRoomIds: [],
image: "ghcr.io/matrix-construct/tuwunel:v1.5.1",
roomId: "!room:matrix-qa.test",
roomIds: ["!room:matrix-qa.test"],
serverName: "matrix-qa.test",
},
observedEventCount: 6,
scenarios: [
{
id: "matrix-reaction-not-a-reply",
title: "Matrix reactions do not trigger a fresh bot reply",
status: "fail",
details: [
"unexpected SUT reply after reaction from @driver:matrix-qa.test",
"reaction event: $reaction",
"unexpected reply event: $reply",
].join("\n"),
},
],
startedAt: "2026-04-10T10:00:00.000Z",
sutAccountId: "sut",
timings: {
artifactWriteMs: 5,
canaryMs: 40,
harnessBootMs: 100,
initialGatewayBootMs: 200,
provisioningMs: 300,
scenarioGatewayBootMs: 50,
scenarioRestartGatewayMs: 60,
scenarioTransportInterruptMs: 70,
scenarios: [
{
durationMs: 8_000,
gatewayBootMs: 0,
gatewayRestartMs: 0,
id: "matrix-reaction-not-a-reply",
title: "Matrix reactions do not trigger a fresh bot reply",
transportInterruptMs: 0,
},
],
totalMs: 825,
},
userIds: {
driver: "@driver:matrix-qa.test",
observer: "@observer:matrix-qa.test",
sut: "@sut:matrix-qa.test",
},
});
expect(summary).toMatchObject({
counts: {
total: 2,
passed: 1,
failed: 1,
},
scenarios: [
{
id: "matrix-reaction-not-a-reply",
status: "fail",
details: expect.stringContaining("reaction event: $reaction"),
},
],
timings: {
scenarios: [
{
id: "matrix-reaction-not-a-reply",
durationMs: 8_000,
},
],
},
});
const report = renderQaMarkdownReport({
title: "Matrix QA Report",
startedAt: new Date(summary.startedAt),
finishedAt: new Date(summary.finishedAt),
checks: summary.checks,
scenarios: summary.scenarios.map((scenario) => ({
details: scenario.details,
name: scenario.title,
status: scenario.status,
})),
notes: [`observed events: ${summary.observedEventsPath}`],
});
expect(report).toContain("### Matrix reactions do not trigger a fresh bot reply");
expect(report).toContain("unexpected SUT reply after reaction from @driver:matrix-qa.test");
expect(report).toContain("reaction event: $reaction");
expect(report).toContain("observed events: /tmp/observed.json");
});
it("batches Matrix scenarios by config key while preserving stable in-group order", () => {
const scenarios = liveTesting.findMatrixQaScenarios([
"matrix-top-level-reply-shape",
"matrix-room-thread-reply-override",
"matrix-thread-follow-up",
"matrix-room-quiet-streaming-preview",
"matrix-reaction-notification",
]);
expect(
liveTesting.scheduleMatrixQaScenariosByConfig(scenarios).map(({ scenario }) => scenario.id),
).toEqual([
"matrix-thread-follow-up",
"matrix-top-level-reply-shape",
"matrix-reaction-notification",
"matrix-room-thread-reply-override",
"matrix-room-quiet-streaming-preview",
]);
});
it("treats only connected, healthy Matrix accounts as ready", () => {
expect(liveTesting.isMatrixAccountReady({ running: true, connected: true })).toBe(true);
expect(liveTesting.isMatrixAccountReady({ running: true, connected: false })).toBe(false);
expect(
liveTesting.isMatrixAccountReady({
running: true,
connected: true,
restartPending: true,
}),
).toBe(false);
expect(
liveTesting.isMatrixAccountReady({
running: true,
connected: true,
healthState: "degraded",
}),
).toBe(false);
});
it("waits past not-ready Matrix status snapshots until the account is really ready", async () => {
vi.useFakeTimers();
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: false }],
},
})
.mockResolvedValueOnce({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: true }],
},
}),
};
const waitPromise = liveTesting.waitForMatrixChannelReady(gateway as never, "sut", {
timeoutMs: 1_000,
pollMs: 100,
});
await vi.advanceTimersByTimeAsync(100);
await expect(waitPromise).resolves.toBeUndefined();
expect(gateway.call).toHaveBeenCalledTimes(2);
});
it("fails readiness when the Matrix account never reaches a healthy connected state", async () => {
vi.useFakeTimers();
const gateway = {
call: vi.fn().mockResolvedValue({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: true, healthState: "degraded" }],
},
}),
};
const waitPromise = liveTesting.waitForMatrixChannelReady(gateway as never, "sut", {
timeoutMs: 250,
pollMs: 100,
});
const expectation = expect(waitPromise).rejects.toThrow(
'matrix account "sut" did not become ready',
);
await vi.advanceTimersByTimeAsync(300);
await expectation;
});
});

View file

@ -0,0 +1,947 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { loadQaRuntimeModule } from "openclaw/plugin-sdk/qa-runner-runtime";
import type { QaReportCheck } from "../../report.js";
import { renderQaMarkdownReport } from "../../report.js";
import { type QaProviderModeInput } from "../../run-config.js";
import {
appendLiveLaneIssue,
buildLiveLaneArtifactsError,
} from "../../shared/live-lane-helpers.js";
import { buildMatrixQaObservedEventsArtifact } from "../../substrate/artifacts.js";
import { provisionMatrixQaRoom, type MatrixQaProvisionResult } from "../../substrate/client.js";
import {
buildMatrixQaConfig,
buildMatrixQaConfigSnapshot,
summarizeMatrixQaConfigSnapshot,
type MatrixQaConfigOverrides,
type MatrixQaConfigSnapshot,
} from "../../substrate/config.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { startMatrixQaHarness } from "../../substrate/harness.runtime.js";
import { resolveMatrixQaModels } from "./model-selection.js";
import type { MatrixQaSyncStreams } from "./scenario-runtime-shared.js";
import {
MATRIX_QA_SCENARIOS,
buildMatrixQaTopologyForScenarios,
buildMatrixReplyDetails,
findMatrixQaScenarios,
runMatrixQaCanary,
runMatrixQaScenario,
type MatrixQaCanaryArtifact,
type MatrixQaScenarioArtifacts,
} from "./scenarios.js";
type MatrixQaGatewayChild = {
call(
method: string,
params: Record<string, unknown>,
options?: { timeoutMs?: number },
): Promise<unknown>;
restartAfterStateMutation?: (
mutateState: (context: { stateDir: string }) => Promise<void>,
) => Promise<void>;
restart(): Promise<void>;
runtimeEnv?: NodeJS.ProcessEnv;
};
type MatrixQaLiveLaneGatewayHarness = {
gateway: MatrixQaGatewayChild;
stop(): Promise<void>;
};
function buildMatrixQaGatewayConfigKey(overrides?: MatrixQaConfigOverrides) {
return JSON.stringify(overrides ?? null);
}
type MatrixQaScenarioResult = {
artifacts?: MatrixQaScenarioArtifacts;
details: string;
id: string;
status: "fail" | "pass";
title: string;
};
type MatrixQaScheduledScenario = {
originalIndex: number;
scenario: (typeof MATRIX_QA_SCENARIOS)[number];
};
type MatrixQaScenarioConfigEntry = MatrixQaSummary["config"]["scenarios"][number];
type MatrixQaSummary = {
checks: QaReportCheck[];
config: {
default: MatrixQaConfigSnapshot;
scenarios: Array<{
config: MatrixQaConfigSnapshot;
id: string;
title: string;
}>;
};
counts: {
failed: number;
passed: number;
total: number;
};
finishedAt: string;
harness: {
baseUrl: string;
composeFile: string;
dmRoomIds: string[];
image: string;
roomId: string;
roomIds: string[];
serverName: string;
};
canary?: MatrixQaCanaryArtifact;
observedEventCount: number;
observedEventsPath: string;
reportPath: string;
scenarios: MatrixQaScenarioResult[];
startedAt: string;
summaryPath: string;
sutAccountId: string;
timings: MatrixQaTimings;
userIds: {
driver: string;
observer: string;
sut: string;
};
};
type MatrixQaArtifactPaths = {
observedEvents: string;
report: string;
summary: string;
};
type MatrixQaScenarioTiming = {
durationMs: number;
gatewayBootMs: number;
gatewayRestartMs: number;
id: string;
title: string;
transportInterruptMs: number;
};
type MatrixQaTimings = {
artifactWriteMs: number;
canaryMs?: number;
harnessBootMs: number;
initialGatewayBootMs: number;
provisioningMs: number;
scenarioGatewayBootMs: number;
scenarioRestartGatewayMs: number;
scenarioTransportInterruptMs: number;
scenarios: MatrixQaScenarioTiming[];
totalMs: number;
};
function shouldWriteMatrixQaProgress() {
const override = process.env.OPENCLAW_QA_MATRIX_PROGRESS;
if (override === "0") {
return false;
}
if (override === "1") {
return true;
}
return process.stderr.isTTY;
}
function formatMatrixQaDurationMs(durationMs: number) {
return durationMs >= 1_000 ? `${(durationMs / 1_000).toFixed(1)}s` : `${durationMs}ms`;
}
function writeMatrixQaProgress(message: string) {
if (!shouldWriteMatrixQaProgress()) {
return;
}
process.stderr.write(`[matrix-qa] ${message}\n`);
}
function countMatrixQaStatuses(entries: Array<{ status: "fail" | "pass" | "skip" }>) {
return {
failed: entries.filter((entry) => entry.status === "fail").length,
passed: entries.filter((entry) => entry.status === "pass").length,
};
}
function formatMatrixQaScenarioDetails(params: { details: string; configSummary?: string }) {
if (!params.configSummary) {
return params.details;
}
return [`effective config: ${params.configSummary}`, params.details].join("\n");
}
function buildMatrixQaScenarioConfigEntry(params: {
gatewayConfigParams: {
driverUserId: string;
homeserver: string;
observerUserId: string;
sutAccessToken: string;
sutAccountId: string;
sutDeviceId?: string;
sutUserId: string;
topology: MatrixQaProvisionResult["topology"];
};
scenario: (typeof MATRIX_QA_SCENARIOS)[number];
}): {
entry: MatrixQaScenarioConfigEntry;
summary?: string;
} {
const snapshot = buildMatrixQaConfigSnapshot({
...params.gatewayConfigParams,
overrides: params.scenario.configOverrides,
});
return {
entry: {
config: snapshot,
id: params.scenario.id,
title: params.scenario.title,
},
summary:
params.scenario.configOverrides === undefined
? undefined
: summarizeMatrixQaConfigSnapshot(snapshot),
};
}
function buildMatrixQaScenarioResult(params: {
artifacts?: MatrixQaScenarioArtifacts;
configSummary?: string;
details: string;
scenario: {
id: string;
title: string;
};
status: "fail" | "pass";
}): MatrixQaScenarioResult {
return {
artifacts: params.artifacts,
id: params.scenario.id,
title: params.scenario.title,
status: params.status,
details: formatMatrixQaScenarioDetails({
details: params.details,
configSummary: params.configSummary,
}),
};
}
function scheduleMatrixQaScenariosByConfig(
scenarios: readonly (typeof MATRIX_QA_SCENARIOS)[number][],
): MatrixQaScheduledScenario[] {
const grouped = new Map<string, MatrixQaScheduledScenario[]>();
scenarios.forEach((scenario, originalIndex) => {
const configKey = buildMatrixQaGatewayConfigKey(scenario.configOverrides);
const existing = grouped.get(configKey);
const scheduled = { originalIndex, scenario };
if (existing) {
existing.push(scheduled);
return;
}
grouped.set(configKey, [scheduled]);
});
return [...grouped.values()].flat();
}
export type MatrixQaRunResult = {
observedEventsPath: string;
outputDir: string;
reportPath: string;
scenarios: MatrixQaScenarioResult[];
summaryPath: string;
};
function buildMatrixQaSummary(params: {
artifactPaths: MatrixQaArtifactPaths;
canary?: MatrixQaCanaryArtifact;
checks: QaReportCheck[];
config: MatrixQaSummary["config"];
finishedAt: string;
harness: MatrixQaSummary["harness"];
observedEventCount: number;
scenarios: MatrixQaScenarioResult[];
startedAt: string;
sutAccountId: string;
timings: MatrixQaTimings;
userIds: MatrixQaSummary["userIds"];
}): MatrixQaSummary {
const checkCounts = countMatrixQaStatuses(params.checks);
const scenarioCounts = countMatrixQaStatuses(params.scenarios);
return {
checks: params.checks,
config: params.config,
counts: {
total: params.checks.length + params.scenarios.length,
passed: checkCounts.passed + scenarioCounts.passed,
failed: checkCounts.failed + scenarioCounts.failed,
},
finishedAt: params.finishedAt,
harness: params.harness,
canary: params.canary,
observedEventCount: params.observedEventCount,
observedEventsPath: params.artifactPaths.observedEvents,
reportPath: params.artifactPaths.report,
scenarios: params.scenarios,
startedAt: params.startedAt,
summaryPath: params.artifactPaths.summary,
sutAccountId: params.sutAccountId,
timings: params.timings,
userIds: params.userIds,
};
}
async function measureMatrixQaStep<T>(step: () => Promise<T>) {
const startedAtMs = Date.now();
const result = await step();
return {
durationMs: Date.now() - startedAtMs,
result,
};
}
function isMatrixAccountReady(entry?: {
connected?: boolean;
healthState?: string;
restartPending?: boolean;
running?: boolean;
}): boolean {
return (
entry?.running === true &&
entry.connected === true &&
entry.restartPending !== true &&
(entry.healthState === undefined || entry.healthState === "healthy")
);
}
async function waitForMatrixChannelReady(
gateway: MatrixQaGatewayChild,
accountId: string,
opts?: {
pollMs?: number;
timeoutMs?: number;
},
) {
const pollMs = opts?.pollMs ?? 500;
const timeoutMs = opts?.timeoutMs ?? 60_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const payload = (await gateway.call(
"channels.status",
{ probe: false, timeoutMs: 2_000 },
{ timeoutMs: 5_000 },
)) as {
channelAccounts?: Record<
string,
Array<{
accountId?: string;
connected?: boolean;
healthState?: string;
restartPending?: boolean;
running?: boolean;
}>
>;
};
const accounts = payload.channelAccounts?.matrix ?? [];
const match = accounts.find((entry) => entry.accountId === accountId);
if (isMatrixAccountReady(match)) {
return;
}
} catch {
// retry
}
await sleep(pollMs);
}
throw new Error(`matrix account "${accountId}" did not become ready`);
}
async function patchMatrixQaGatewayConfig(params: {
gateway: MatrixQaGatewayChild;
patch: Record<string, unknown>;
restartDelayMs?: number;
}) {
const snapshot = (await params.gateway.call("config.get", {}, { timeoutMs: 60_000 })) as {
hash?: string;
};
if (!snapshot.hash) {
throw new Error("Matrix QA config patch requires config.get hash");
}
await params.gateway.call(
"config.patch",
{
raw: JSON.stringify(params.patch, null, 2),
baseHash: snapshot.hash,
restartDelayMs: params.restartDelayMs ?? 0,
},
{ timeoutMs: 60_000 },
);
}
async function startMatrixQaLiveLaneGateway(params: {
repoRoot: string;
transport: {
requiredPluginIds: readonly string[];
createGatewayConfig: (params: {
baseUrl: string;
}) => Pick<OpenClawConfig, "channels" | "messages">;
};
transportBaseUrl: string;
providerMode: "mock-openai" | "live-frontier";
primaryModel: string;
alternateModel: string;
fastMode?: boolean;
controlUiEnabled?: boolean;
mutateConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
}): Promise<MatrixQaLiveLaneGatewayHarness> {
return (await loadQaRuntimeModule().startQaLiveLaneGateway(
params,
)) as MatrixQaLiveLaneGatewayHarness;
}
export async function runMatrixQaLive(params: {
fastMode?: boolean;
outputDir?: string;
primaryModel?: string;
providerMode?: QaProviderModeInput;
repoRoot?: string;
scenarioIds?: string[];
sutAccountId?: string;
alternateModel?: string;
}): Promise<MatrixQaRunResult> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `matrix-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const { providerMode, primaryModel, alternateModel } = resolveMatrixQaModels({
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
});
const sutAccountId = params.sutAccountId?.trim() || "sut";
const scenarios = findMatrixQaScenarios(params.scenarioIds);
const runSuffix = randomUUID().slice(0, 8);
const topology = buildMatrixQaTopologyForScenarios({
defaultRoomName: `OpenClaw Matrix QA ${runSuffix}`,
scenarios,
});
const observedEvents: MatrixQaObservedEvent[] = [];
const includeObservedEventContent = process.env.OPENCLAW_QA_MATRIX_CAPTURE_CONTENT === "1";
const startedAtDate = new Date();
const startedAt = startedAtDate.toISOString();
const runStartedAtMs = Date.now();
writeMatrixQaProgress(
`suite start scenarios=${scenarios.length} provider=${providerMode} output=${outputDir}`,
);
const { durationMs: harnessBootMs, result: harness } = await measureMatrixQaStep(() =>
startMatrixQaHarness({
outputDir: path.join(outputDir, "matrix-harness"),
repoRoot,
}),
);
writeMatrixQaProgress(
`harness ready ${formatMatrixQaDurationMs(harnessBootMs)} baseUrl=${harness.baseUrl}`,
);
const { durationMs: provisioningMs, result: provisioning } = await (async () => {
try {
return await measureMatrixQaStep(() =>
provisionMatrixQaRoom({
baseUrl: harness.baseUrl,
driverLocalpart: `qa-driver-${runSuffix}`,
observerLocalpart: `qa-observer-${runSuffix}`,
registrationToken: harness.registrationToken,
roomName: `OpenClaw Matrix QA ${runSuffix}`,
sutLocalpart: `qa-sut-${runSuffix}`,
topology,
}),
);
} catch (error) {
await harness.stop().catch(() => {});
throw error;
}
})();
writeMatrixQaProgress(
`topology ready ${formatMatrixQaDurationMs(provisioningMs)} rooms=${provisioning.topology.rooms.length}`,
);
const checks: QaReportCheck[] = [
{
name: "Matrix harness ready",
status: "pass",
details: [
`image: ${harness.image}`,
`baseUrl: ${harness.baseUrl}`,
`serverName: ${harness.serverName}`,
`roomId: ${provisioning.roomId}`,
`roomCount: ${provisioning.topology.rooms.length}`,
].join("\n"),
},
];
const scenarioResults: Array<MatrixQaScenarioResult | undefined> = Array.from({
length: scenarios.length,
});
const cleanupErrors: string[] = [];
let canaryArtifact: MatrixQaCanaryArtifact | undefined;
let gatewayHarness: MatrixQaLiveLaneGatewayHarness | null = null;
let gatewayHarnessKey: string | null = null;
let canaryFailed = false;
const syncState: { driver?: string; observer?: string } = {};
const syncStreams: MatrixQaSyncStreams = {};
let canaryMs: number | undefined;
let initialGatewayBootMs = 0;
let scenarioGatewayBootMs = 0;
let scenarioRestartGatewayMs = 0;
let scenarioTransportInterruptMs = 0;
const scenarioTimings: MatrixQaScenarioTiming[] = [];
const gatewayConfigParams = {
driverUserId: provisioning.driver.userId,
homeserver: harness.baseUrl,
observerUserId: provisioning.observer.userId,
sutAccessToken: provisioning.sut.accessToken,
sutAccountId,
sutDeviceId: provisioning.sut.deviceId,
sutUserId: provisioning.sut.userId,
topology: provisioning.topology,
};
const defaultConfigSnapshot = buildMatrixQaConfigSnapshot(gatewayConfigParams);
const scenarioConfigSnapshots: MatrixQaScenarioConfigEntry[] = [];
const scheduledScenarios = scheduleMatrixQaScenariosByConfig(scenarios);
try {
const ensureGatewayHarness = async (overrides?: MatrixQaConfigOverrides) => {
const nextKey = buildMatrixQaGatewayConfigKey(overrides);
if (gatewayHarness && gatewayHarnessKey === nextKey) {
return {
durationMs: 0,
harness: gatewayHarness,
};
}
if (gatewayHarness) {
await gatewayHarness.stop();
gatewayHarness = null;
gatewayHarnessKey = nextKey;
}
writeMatrixQaProgress("gateway boot start");
const { durationMs, result: started } = await measureMatrixQaStep(async () => {
const nextHarness = await startMatrixQaLiveLaneGateway({
repoRoot,
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1:43123",
providerMode,
primaryModel,
alternateModel,
fastMode: params.fastMode,
controlUiEnabled: false,
mutateConfig: (cfg) =>
buildMatrixQaConfig(cfg, {
...gatewayConfigParams,
overrides,
}),
});
await waitForMatrixChannelReady(nextHarness.gateway, sutAccountId);
return nextHarness;
});
writeMatrixQaProgress(`gateway boot done ${formatMatrixQaDurationMs(durationMs)}`);
gatewayHarness = started;
gatewayHarnessKey = nextKey;
return {
durationMs,
harness: started,
};
};
{
const ensured = await ensureGatewayHarness();
gatewayHarness = ensured.harness;
initialGatewayBootMs = ensured.durationMs;
}
checks.push({
name: "Matrix channel ready",
status: "pass",
details: `accountId: ${sutAccountId}\nuserId: ${provisioning.sut.userId}`,
});
try {
writeMatrixQaProgress("canary start");
const canaryMeasured = await measureMatrixQaStep(() =>
runMatrixQaCanary({
baseUrl: harness.baseUrl,
driverAccessToken: provisioning.driver.accessToken,
observedEvents,
roomId: provisioning.roomId,
syncState,
syncStreams,
sutUserId: provisioning.sut.userId,
timeoutMs: 45_000,
}),
);
canaryMs = canaryMeasured.durationMs;
const canary = canaryMeasured.result;
canaryArtifact = {
driverEventId: canary.driverEventId,
reply: canary.reply,
token: canary.token,
};
checks.push({
name: "Matrix canary",
status: "pass",
details: buildMatrixReplyDetails("reply", canary.reply).join("\n"),
});
writeMatrixQaProgress(`canary pass ${formatMatrixQaDurationMs(canaryMeasured.durationMs)}`);
} catch (error) {
canaryFailed = true;
checks.push({
name: "Matrix canary",
status: "fail",
details: formatErrorMessage(error),
});
writeMatrixQaProgress(`canary fail ${formatErrorMessage(error)}`);
}
if (!canaryFailed) {
for (const { scenario, originalIndex } of scheduledScenarios) {
const { entry: scenarioConfigEntry, summary: scenarioConfigSummary } =
buildMatrixQaScenarioConfigEntry({
gatewayConfigParams,
scenario,
});
scenarioConfigSnapshots[originalIndex] = scenarioConfigEntry;
let gatewayBootMs = 0;
let gatewayRestartMs = 0;
let transportInterruptMs = 0;
try {
writeMatrixQaProgress(`scenario start ${scenario.id}`);
const scenarioGateway = await ensureGatewayHarness(scenario.configOverrides);
gatewayBootMs = scenarioGateway.durationMs;
scenarioGatewayBootMs += gatewayBootMs;
const measuredScenario = await measureMatrixQaStep(() =>
runMatrixQaScenario(scenario, {
baseUrl: harness.baseUrl,
canary: canaryArtifact,
driverAccessToken: provisioning.driver.accessToken,
driverDeviceId: provisioning.driver.deviceId,
driverPassword: provisioning.driver.password,
driverUserId: provisioning.driver.userId,
interruptTransport: async () => {
writeMatrixQaProgress(`transport interrupt start ${scenario.id}`);
const measuredInterrupt = await measureMatrixQaStep(async () => {
await harness.restartService();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId, {
timeoutMs: 90_000,
});
});
transportInterruptMs += measuredInterrupt.durationMs;
scenarioTransportInterruptMs += measuredInterrupt.durationMs;
writeMatrixQaProgress(
`transport interrupt done ${scenario.id} ${formatMatrixQaDurationMs(measuredInterrupt.durationMs)}`,
);
},
observedEvents,
observerAccessToken: provisioning.observer.accessToken,
observerDeviceId: provisioning.observer.deviceId,
observerPassword: provisioning.observer.password,
observerUserId: provisioning.observer.userId,
gatewayStateDir: scenarioGateway.harness.gateway.runtimeEnv?.OPENCLAW_STATE_DIR,
outputDir,
restartGateway: async () => {
if (!gatewayHarness) {
throw new Error("Matrix restart scenario requires a live gateway");
}
writeMatrixQaProgress(`gateway restart start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await scenarioGateway.harness.gateway.restart();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
writeMatrixQaProgress(
`gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
restartGatewayAfterStateMutation: async (mutateState) => {
if (!gatewayHarness) {
throw new Error(
"Matrix persisted-state restart scenario requires a live gateway",
);
}
const restartAfterStateMutation =
scenarioGateway.harness.gateway.restartAfterStateMutation;
if (!restartAfterStateMutation) {
throw new Error(
"Matrix persisted-state restart scenario requires a hard restart callback",
);
}
writeMatrixQaProgress(`gateway hard restart start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await restartAfterStateMutation(mutateState);
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
writeMatrixQaProgress(
`gateway hard restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
restartGatewayWithQueuedMessage: async (queueMessage) => {
if (!gatewayHarness) {
throw new Error("Matrix restart catchup scenario requires a live gateway");
}
writeMatrixQaProgress(`gateway restart+queue start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await scenarioGateway.harness.gateway.restart();
await sleep(250);
await queueMessage();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
writeMatrixQaProgress(
`gateway restart+queue done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
roomId: provisioning.roomId,
sutAccountId,
sutAccessToken: provisioning.sut.accessToken,
sutDeviceId: provisioning.sut.deviceId,
sutPassword: provisioning.sut.password,
syncState,
syncStreams,
sutUserId: provisioning.sut.userId,
timeoutMs: scenario.timeoutMs,
topology: provisioning.topology,
patchGatewayConfig: async (patch, opts) => {
await patchMatrixQaGatewayConfig({
gateway: scenarioGateway.harness.gateway,
patch,
restartDelayMs: opts?.restartDelayMs,
});
},
}),
);
const result = measuredScenario.result;
scenarioTimings[originalIndex] = {
durationMs: measuredScenario.durationMs,
gatewayBootMs,
gatewayRestartMs,
id: scenario.id,
title: scenario.title,
transportInterruptMs,
};
scenarioResults[originalIndex] = buildMatrixQaScenarioResult({
artifacts: result.artifacts,
configSummary: scenarioConfigSummary,
details: result.details,
scenario,
status: "pass",
});
writeMatrixQaProgress(
`scenario pass ${scenario.id} ${formatMatrixQaDurationMs(measuredScenario.durationMs)}`,
);
} catch (error) {
scenarioTimings[originalIndex] = {
durationMs: 0,
gatewayBootMs,
gatewayRestartMs,
id: scenario.id,
title: scenario.title,
transportInterruptMs,
};
scenarioResults[originalIndex] = buildMatrixQaScenarioResult({
configSummary: scenarioConfigSummary,
details: formatErrorMessage(error),
scenario,
status: "fail",
});
writeMatrixQaProgress(`scenario fail ${scenario.id} ${formatErrorMessage(error)}`);
}
}
}
} finally {
if (gatewayHarness) {
try {
await gatewayHarness.stop();
} catch (error) {
appendLiveLaneIssue(cleanupErrors, "live gateway cleanup", error);
}
}
try {
await harness.stop();
} catch (error) {
appendLiveLaneIssue(cleanupErrors, "Matrix harness cleanup", error);
}
}
const completedScenarioResults = scenarioResults.filter(
(scenario): scenario is MatrixQaScenarioResult => scenario !== undefined,
);
if (cleanupErrors.length > 0) {
checks.push({
name: "Matrix cleanup",
status: "fail",
details: cleanupErrors.join("\n"),
});
}
const finishedAtDate = new Date();
const finishedAt = finishedAtDate.toISOString();
const reportPath = path.join(outputDir, "matrix-qa-report.md");
const summaryPath = path.join(outputDir, "matrix-qa-summary.json");
const observedEventsPath = path.join(outputDir, "matrix-qa-observed-events.json");
const artifactPaths = {
observedEvents: observedEventsPath,
report: reportPath,
summary: summaryPath,
} satisfies MatrixQaArtifactPaths;
const report = renderQaMarkdownReport({
title: "Matrix QA Report",
startedAt: startedAtDate,
finishedAt: finishedAtDate,
checks,
scenarios: completedScenarioResults.map((scenario) => ({
details: scenario.details,
name: scenario.title,
status: scenario.status,
})),
notes: [
`roomId: ${provisioning.roomId}`,
`roomIds: ${provisioning.topology.rooms.map((room) => room.roomId).join(", ")}`,
`default config: ${summarizeMatrixQaConfigSnapshot(defaultConfigSnapshot)}`,
`driver: ${provisioning.driver.userId}`,
`observer: ${provisioning.observer.userId}`,
`sut: ${provisioning.sut.userId}`,
`homeserver: ${harness.baseUrl}`,
`image: ${harness.image}`,
`timings: harness=${harnessBootMs}ms provisioning=${provisioningMs}ms gateway=${initialGatewayBootMs}ms canary=${canaryMs ?? 0}ms`,
],
});
const artifactWriteStartedAtMs = Date.now();
const summary: MatrixQaSummary = buildMatrixQaSummary({
artifactPaths,
canary: canaryArtifact,
checks,
config: {
default: defaultConfigSnapshot,
scenarios: scenarioConfigSnapshots,
},
finishedAt,
harness: {
baseUrl: harness.baseUrl,
composeFile: harness.composeFile,
dmRoomIds: provisioning.topology.rooms
.filter((room) => room.kind === "dm")
.map((room) => room.roomId),
image: harness.image,
roomId: provisioning.roomId,
roomIds: provisioning.topology.rooms.map((room) => room.roomId),
serverName: harness.serverName,
},
observedEventCount: observedEvents.length,
scenarios: completedScenarioResults,
startedAt,
sutAccountId,
timings: {
artifactWriteMs: 0,
canaryMs,
harnessBootMs,
initialGatewayBootMs,
provisioningMs,
scenarioGatewayBootMs,
scenarioRestartGatewayMs,
scenarioTransportInterruptMs,
scenarios: scenarioTimings,
totalMs: Date.now() - runStartedAtMs,
},
userIds: {
driver: provisioning.driver.userId,
observer: provisioning.observer.userId,
sut: provisioning.sut.userId,
},
});
await fs.writeFile(reportPath, `${report}\n`, { encoding: "utf8", mode: 0o600 });
await fs.writeFile(
observedEventsPath,
`${JSON.stringify(
buildMatrixQaObservedEventsArtifact({
includeContent: includeObservedEventContent,
observedEvents,
}),
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
summary.timings.artifactWriteMs = Date.now() - artifactWriteStartedAtMs;
summary.timings.totalMs = Date.now() - runStartedAtMs;
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
writeMatrixQaProgress(
`suite ${summary.counts.failed > 0 ? "fail" : "pass"} ${summary.counts.passed}/${summary.counts.total} total=${formatMatrixQaDurationMs(summary.timings.totalMs)}`,
);
const failedChecks = checks.filter(
(check) => check.status === "fail" && check.name !== "Matrix cleanup",
);
const failedScenarios = completedScenarioResults.filter((scenario) => scenario.status === "fail");
if (failedChecks.length > 0 || failedScenarios.length > 0) {
throw new Error(
buildLiveLaneArtifactsError({
heading: "Matrix QA failed.",
details: [
...failedChecks.map((check) => `check ${check.name}: ${check.details ?? "failed"}`),
...failedScenarios.map((scenario) => `scenario ${scenario.id}: ${scenario.details}`),
...cleanupErrors.map((error) => `cleanup: ${error}`),
],
artifacts: artifactPaths,
}),
);
}
if (cleanupErrors.length > 0) {
throw new Error(
buildLiveLaneArtifactsError({
heading: "Matrix QA cleanup failed after artifacts were written.",
details: cleanupErrors,
artifacts: artifactPaths,
}),
);
}
return {
observedEventsPath,
outputDir,
reportPath,
scenarios: completedScenarioResults,
summaryPath,
};
}
export const __testing = {
buildMatrixQaSummary,
scheduleMatrixQaScenariosByConfig,
MATRIX_QA_SCENARIOS,
buildMatrixQaConfig,
buildMatrixQaConfigSnapshot,
findMatrixQaScenarios,
isMatrixAccountReady,
patchMatrixQaGatewayConfig,
resolveMatrixQaModels,
summarizeMatrixQaConfigSnapshot,
waitForMatrixChannelReady,
};

View file

@ -0,0 +1,680 @@
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
type LiveTransportScenarioDefinition,
} from "../../shared/live-transport-scenarios.js";
import { type MatrixQaConfigOverrides } from "../../substrate/config.js";
import {
buildDefaultMatrixQaTopologySpec,
findMatrixQaProvisionedRoom,
mergeMatrixQaTopologySpecs,
type MatrixQaProvisionedTopology,
type MatrixQaTopologySpec,
} from "../../substrate/topology.js";
export type MatrixQaScenarioId =
| "matrix-thread-follow-up"
| "matrix-thread-root-preservation"
| "matrix-thread-nested-reply-shape"
| "matrix-thread-isolation"
| "matrix-subagent-thread-spawn"
| "matrix-top-level-reply-shape"
| "matrix-room-thread-reply-override"
| "matrix-room-quiet-streaming-preview"
| "matrix-room-block-streaming"
| "matrix-room-image-understanding-attachment"
| "matrix-room-generated-image-delivery"
| "matrix-media-type-coverage"
| "matrix-attachment-only-ignored"
| "matrix-unsupported-media-safe"
| "matrix-dm-reply-shape"
| "matrix-dm-shared-session-notice"
| "matrix-dm-thread-reply-override"
| "matrix-dm-per-room-session-override"
| "matrix-room-autojoin-invite"
| "matrix-secondary-room-reply"
| "matrix-secondary-room-open-trigger"
| "matrix-reaction-notification"
| "matrix-reaction-threaded"
| "matrix-reaction-not-a-reply"
| "matrix-reaction-redaction-observed"
| "matrix-restart-resume"
| "matrix-post-restart-room-continue"
| "matrix-initial-catchup-then-incremental"
| "matrix-restart-replay-dedupe"
| "matrix-stale-sync-replay-dedupe"
| "matrix-room-membership-loss"
| "matrix-homeserver-restart-resume"
| "matrix-mention-gating"
| "matrix-mxid-prefixed-command-block"
| "matrix-mention-metadata-spoof-block"
| "matrix-observer-allowlist-override"
| "matrix-allowlist-block"
| "matrix-allowlist-hot-reload"
| "matrix-multi-actor-ordering"
| "matrix-inbound-edit-ignored"
| "matrix-inbound-edit-no-duplicate-trigger"
| "matrix-e2ee-basic-reply"
| "matrix-e2ee-thread-follow-up"
| "matrix-e2ee-bootstrap-success"
| "matrix-e2ee-recovery-key-lifecycle"
| "matrix-e2ee-device-sas-verification"
| "matrix-e2ee-qr-verification"
| "matrix-e2ee-stale-device-hygiene"
| "matrix-e2ee-dm-sas-verification"
| "matrix-e2ee-restart-resume"
| "matrix-e2ee-verification-notice-no-trigger"
| "matrix-e2ee-artifact-redaction"
| "matrix-e2ee-media-image"
| "matrix-e2ee-key-bootstrap-failure";
export type MatrixQaE2eeScenarioId = Extract<MatrixQaScenarioId, `matrix-e2ee-${string}`>;
export type MatrixQaScenarioDefinition = LiveTransportScenarioDefinition<MatrixQaScenarioId> & {
configOverrides?: MatrixQaConfigOverrides;
topology?: MatrixQaTopologySpec;
};
export const MATRIX_QA_BLOCK_ROOM_KEY = "block";
export const MATRIX_QA_DRIVER_DM_ROOM_KEY = "driver-dm";
export const MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY = "driver-dm-shared";
export const MATRIX_QA_E2EE_ROOM_KEY = "e2ee";
export const MATRIX_QA_E2EE_VERIFICATION_DM_ROOM_KEY = "e2ee-verification-dm";
export const MATRIX_QA_HOMESERVER_ROOM_KEY = "homeserver";
export const MATRIX_QA_MAIN_ROOM_KEY = "main";
export const MATRIX_QA_MEDIA_ROOM_KEY = "media";
export const MATRIX_QA_MEMBERSHIP_ROOM_KEY = "membership";
export const MATRIX_QA_RESTART_ROOM_KEY = "restart";
export const MATRIX_QA_SECONDARY_ROOM_KEY = "secondary";
function buildMatrixQaDmTopology(
rooms: Array<{
key: string;
name: string;
}>,
): MatrixQaTopologySpec {
return {
defaultRoomKey: MATRIX_QA_MAIN_ROOM_KEY,
rooms: rooms.map((room) => ({
key: room.key,
kind: "dm" as const,
members: ["driver", "sut"],
name: room.name,
})),
};
}
function buildMatrixQaSingleGroupTopology(params: {
encrypted?: boolean;
key: string;
name: string;
requireMention: boolean;
}): MatrixQaTopologySpec {
return {
defaultRoomKey: MATRIX_QA_MAIN_ROOM_KEY,
rooms: [
{
encrypted: params.encrypted === true,
key: params.key,
kind: "group",
members: ["driver", "observer", "sut"],
name: params.name,
requireMention: params.requireMention,
},
],
};
}
export function buildMatrixQaE2eeScenarioRoomKey(scenarioId: MatrixQaE2eeScenarioId) {
const suffix = scenarioId.replace(/^matrix-e2ee-/, "").replace(/[^A-Za-z0-9_-]/g, "-");
return `${MATRIX_QA_E2EE_ROOM_KEY}-${suffix}`;
}
function buildMatrixQaE2eeScenarioTopology(params: {
scenarioId: MatrixQaE2eeScenarioId;
name: string;
}): MatrixQaTopologySpec {
return buildMatrixQaSingleGroupTopology({
encrypted: true,
key: buildMatrixQaE2eeScenarioRoomKey(params.scenarioId),
name: params.name,
requireMention: true,
});
}
const MATRIX_QA_DRIVER_DM_TOPOLOGY = buildMatrixQaDmTopology([
{
key: MATRIX_QA_DRIVER_DM_ROOM_KEY,
name: "Matrix QA Driver/SUT DM",
},
]);
const MATRIX_QA_SHARED_DM_TOPOLOGY = buildMatrixQaDmTopology([
{
key: MATRIX_QA_DRIVER_DM_ROOM_KEY,
name: "Matrix QA Driver/SUT DM",
},
{
key: MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
name: "Matrix QA Driver/SUT Shared DM",
},
]);
const MATRIX_QA_SECONDARY_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_SECONDARY_ROOM_KEY,
name: "Matrix QA Secondary Room",
requireMention: true,
});
const MATRIX_QA_BLOCK_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_BLOCK_ROOM_KEY,
name: "Matrix QA Block Streaming Room",
requireMention: true,
});
const MATRIX_QA_MEMBERSHIP_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_MEMBERSHIP_ROOM_KEY,
name: "Matrix QA Membership Room",
requireMention: true,
});
const MATRIX_QA_MEDIA_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_MEDIA_ROOM_KEY,
name: "Matrix QA Media Room",
requireMention: true,
});
const MATRIX_QA_RESTART_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_RESTART_ROOM_KEY,
name: "Matrix QA Restart Room",
requireMention: true,
});
const MATRIX_QA_HOMESERVER_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
key: MATRIX_QA_HOMESERVER_ROOM_KEY,
name: "Matrix QA Homeserver Restart Room",
requireMention: true,
});
const MATRIX_QA_E2EE_VERIFICATION_DM_TOPOLOGY: MatrixQaTopologySpec = {
defaultRoomKey: "main",
rooms: [
{
encrypted: true,
key: MATRIX_QA_E2EE_VERIFICATION_DM_ROOM_KEY,
kind: "dm",
members: ["driver", "observer"],
name: "Matrix QA E2EE Verification DM",
},
],
};
const MATRIX_QA_E2EE_CONFIG = {
encryption: true,
startupVerification: "off",
} satisfies MatrixQaConfigOverrides;
export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
{
id: "matrix-thread-follow-up",
standardId: "thread-follow-up",
timeoutMs: 60_000,
title: "Matrix thread follow-up reply",
},
{
id: "matrix-thread-root-preservation",
timeoutMs: 60_000,
title: "Matrix threaded replies keep the original root event",
},
{
id: "matrix-thread-nested-reply-shape",
timeoutMs: 60_000,
title: "Matrix nested threaded replies keep fallback replies on the root event",
},
{
id: "matrix-thread-isolation",
standardId: "thread-isolation",
timeoutMs: 75_000,
title: "Matrix top-level reply stays out of prior thread",
},
{
id: "matrix-subagent-thread-spawn",
timeoutMs: 75_000,
title: "Matrix sessions_spawn thread=true creates a bound child thread",
configOverrides: {
groupsByKey: {
[MATRIX_QA_MAIN_ROOM_KEY]: {
tools: {
allow: ["sessions_spawn"],
},
},
},
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
},
toolProfile: "coding",
},
},
{
id: "matrix-top-level-reply-shape",
standardId: "top-level-reply-shape",
timeoutMs: 45_000,
title: "Matrix top-level reply keeps replyToMode off",
},
{
id: "matrix-room-thread-reply-override",
timeoutMs: 45_000,
title: "Matrix threadReplies always keeps room replies threaded",
configOverrides: {
threadReplies: "always",
},
},
{
id: "matrix-room-quiet-streaming-preview",
timeoutMs: 45_000,
title: "Matrix quiet streaming emits notice previews before finalizing",
configOverrides: {
streaming: "quiet",
},
},
{
id: "matrix-room-block-streaming",
timeoutMs: 45_000,
title: "Matrix block streaming preserves completed quiet preview blocks",
topology: MATRIX_QA_BLOCK_ROOM_TOPOLOGY,
configOverrides: {
agentDefaults: {
blockStreamingChunk: {
breakPreference: "newline",
maxChars: 48,
minChars: 1,
},
blockStreamingCoalesce: {
idleMs: 0,
maxChars: 48,
minChars: 1,
},
},
blockStreaming: true,
streaming: "quiet",
},
},
{
id: "matrix-room-image-understanding-attachment",
timeoutMs: 60_000,
title: "Matrix captioned image attachments reach the model vision path",
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
},
{
id: "matrix-room-generated-image-delivery",
timeoutMs: 60_000,
title: "Matrix generated images deliver as real image attachments while streaming",
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
configOverrides: {
streaming: "quiet",
},
},
{
id: "matrix-media-type-coverage",
timeoutMs: 90_000,
title: "Matrix media attachments cover image, audio, video, PDF, and EPUB transport",
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
},
{
id: "matrix-attachment-only-ignored",
timeoutMs: 8_000,
title: "Matrix attachment-only group media does not bypass mention gating",
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
},
{
id: "matrix-unsupported-media-safe",
timeoutMs: 45_000,
title: "Matrix unsupported media attachments do not block caption replies",
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
},
{
id: "matrix-dm-reply-shape",
timeoutMs: 45_000,
title: "Matrix DM reply stays top-level without a mention",
topology: MATRIX_QA_DRIVER_DM_TOPOLOGY,
},
{
id: "matrix-dm-shared-session-notice",
timeoutMs: 45_000,
title: "Matrix shared DM sessions emit a cross-room notice",
topology: MATRIX_QA_SHARED_DM_TOPOLOGY,
},
{
id: "matrix-dm-thread-reply-override",
timeoutMs: 45_000,
title: "Matrix DM thread override keeps DM replies threaded",
topology: MATRIX_QA_DRIVER_DM_TOPOLOGY,
configOverrides: {
dm: {
threadReplies: "always",
},
threadReplies: "off",
},
},
{
id: "matrix-dm-per-room-session-override",
timeoutMs: 45_000,
title: "Matrix DM per-room session override suppresses cross-room notices",
topology: MATRIX_QA_SHARED_DM_TOPOLOGY,
configOverrides: {
dm: {
sessionScope: "per-room",
},
},
},
{
id: "matrix-room-autojoin-invite",
timeoutMs: 60_000,
title: "Matrix invite auto-join accepts fresh group rooms",
configOverrides: {
autoJoin: "always",
groupPolicy: "open",
},
},
{
id: "matrix-secondary-room-reply",
timeoutMs: 45_000,
title: "Matrix secondary room reply stays scoped to that room",
topology: MATRIX_QA_SECONDARY_ROOM_TOPOLOGY,
},
{
id: "matrix-secondary-room-open-trigger",
timeoutMs: 45_000,
title: "Matrix secondary room can opt out of mention gating",
topology: MATRIX_QA_SECONDARY_ROOM_TOPOLOGY,
configOverrides: {
groupsByKey: {
[MATRIX_QA_SECONDARY_ROOM_KEY]: {
requireMention: false,
},
},
},
},
{
id: "matrix-reaction-notification",
standardId: "reaction-observation",
timeoutMs: 45_000,
title: "Matrix reactions on bot replies are observed",
},
{
id: "matrix-reaction-threaded",
timeoutMs: 45_000,
title: "Matrix reactions preserve threaded reply targets",
},
{
id: "matrix-reaction-not-a-reply",
timeoutMs: 8_000,
title: "Matrix reactions do not trigger a fresh bot reply",
},
{
id: "matrix-reaction-redaction-observed",
timeoutMs: 45_000,
title: "Matrix reaction removals are observed as redactions",
},
{
id: "matrix-restart-resume",
standardId: "restart-resume",
timeoutMs: 60_000,
title: "Matrix lane resumes cleanly after gateway restart",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-post-restart-room-continue",
timeoutMs: 75_000,
title: "Matrix restarted room continues after the first recovered reply",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-initial-catchup-then-incremental",
timeoutMs: 90_000,
title: "Matrix initial catchup is followed by incremental replies",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-restart-replay-dedupe",
timeoutMs: 90_000,
title: "Matrix restart does not redeliver a handled event",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-stale-sync-replay-dedupe",
timeoutMs: 90_000,
title: "Matrix stale sync replay is absorbed by inbound dedupe",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-room-membership-loss",
timeoutMs: 75_000,
title: "Matrix room membership loss recovers after re-invite",
topology: MATRIX_QA_MEMBERSHIP_ROOM_TOPOLOGY,
},
{
id: "matrix-homeserver-restart-resume",
timeoutMs: 75_000,
title: "Matrix lane resumes after homeserver restart",
topology: MATRIX_QA_HOMESERVER_ROOM_TOPOLOGY,
},
{
id: "matrix-mention-gating",
standardId: "mention-gating",
timeoutMs: 8_000,
title: "Matrix room message without mention does not trigger",
},
{
id: "matrix-mxid-prefixed-command-block",
timeoutMs: 8_000,
title: "Matrix MXID-prefixed control commands stay gated",
configOverrides: {
groupPolicy: "open",
},
},
{
id: "matrix-mention-metadata-spoof-block",
timeoutMs: 8_000,
title: "Matrix metadata-only mention spoof does not trigger",
},
{
id: "matrix-observer-allowlist-override",
timeoutMs: 45_000,
title: "Matrix sender allowlist override lets observer messages trigger replies",
configOverrides: {
groupAllowRoles: ["driver", "observer"],
},
},
{
id: "matrix-allowlist-block",
standardId: "allowlist-block",
timeoutMs: 8_000,
title: "Matrix sender allowlist blocks observer replies",
},
{
id: "matrix-allowlist-hot-reload",
timeoutMs: 60_000,
title: "Matrix group sender allowlist removals hot-reload without gateway restart",
configOverrides: {
groupAllowRoles: ["driver", "observer"],
},
},
{
id: "matrix-multi-actor-ordering",
timeoutMs: 60_000,
title: "Matrix blocked observer traffic does not poison later driver replies",
},
{
id: "matrix-inbound-edit-ignored",
timeoutMs: 8_000,
title: "Matrix inbound edits cannot turn ignored messages into triggers",
},
{
id: "matrix-inbound-edit-no-duplicate-trigger",
timeoutMs: 45_000,
title: "Matrix inbound edits do not duplicate already handled triggers",
},
{
id: "matrix-e2ee-basic-reply",
timeoutMs: 75_000,
title: "Matrix E2EE encrypted room replies decrypt end-to-end",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-basic-reply",
name: "Matrix QA E2EE Basic Reply Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-thread-follow-up",
timeoutMs: 75_000,
title: "Matrix E2EE encrypted threads preserve reply shape",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-thread-follow-up",
name: "Matrix QA E2EE Thread Follow-up Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-bootstrap-success",
timeoutMs: 90_000,
title: "Matrix E2EE bootstrap verifies the owner device and backup",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-bootstrap-success",
name: "Matrix QA E2EE Bootstrap Success Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-recovery-key-lifecycle",
timeoutMs: 90_000,
title: "Matrix E2EE recovery key restores and resets room-key backup",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-recovery-key-lifecycle",
name: "Matrix QA E2EE Recovery Key Lifecycle Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-device-sas-verification",
timeoutMs: 90_000,
title: "Matrix E2EE device verification completes SAS emoji compare",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-device-sas-verification",
name: "Matrix QA E2EE Device SAS Verification Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-qr-verification",
timeoutMs: 90_000,
title: "Matrix E2EE QR verification completes identity scan",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-qr-verification",
name: "Matrix QA E2EE QR Verification Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-stale-device-hygiene",
timeoutMs: 90_000,
title: "Matrix E2EE stale own devices can be removed without deleting the current device",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-stale-device-hygiene",
name: "Matrix QA E2EE Stale Device Hygiene Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-dm-sas-verification",
timeoutMs: 90_000,
title: "Matrix E2EE DM verification notices stay scoped and complete SAS",
topology: MATRIX_QA_E2EE_VERIFICATION_DM_TOPOLOGY,
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-restart-resume",
timeoutMs: 90_000,
title: "Matrix E2EE encrypted rooms resume after gateway restart",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-restart-resume",
name: "Matrix QA E2EE Restart Resume Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-verification-notice-no-trigger",
timeoutMs: 30_000,
title: "Matrix E2EE verification notices do not trigger replies",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-verification-notice-no-trigger",
name: "Matrix QA E2EE Verification Notice Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-artifact-redaction",
timeoutMs: 75_000,
title: "Matrix E2EE decrypted payloads stay out of default event artifacts",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-artifact-redaction",
name: "Matrix QA E2EE Artifact Redaction Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-media-image",
timeoutMs: 90_000,
title: "Matrix E2EE encrypted image attachments reach the model vision path",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-media-image",
name: "Matrix QA E2EE Media Image Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-key-bootstrap-failure",
timeoutMs: 90_000,
title: "Matrix E2EE bootstrap reports room-key backup failures",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-key-bootstrap-failure",
name: "Matrix QA E2EE Key Bootstrap Failure Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
];
export const MATRIX_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({
alwaysOnStandardScenarioIds: ["canary"],
scenarios: MATRIX_QA_SCENARIOS,
});
export function findMatrixQaScenarios(ids?: string[]) {
return selectLiveTransportScenarios({
ids,
laneLabel: "Matrix",
scenarios: MATRIX_QA_SCENARIOS,
});
}
export function buildMatrixQaTopologyForScenarios(params: {
defaultRoomName: string;
scenarios: MatrixQaScenarioDefinition[];
}): MatrixQaTopologySpec {
return mergeMatrixQaTopologySpecs([
buildDefaultMatrixQaTopologySpec({
defaultRoomName: params.defaultRoomName,
}),
...params.scenarios.flatMap((scenario) => (scenario.topology ? [scenario.topology] : [])),
]);
}
export function resolveMatrixQaScenarioRoomId(
context: Pick<{ roomId: string; topology: MatrixQaProvisionedTopology }, "roomId" | "topology">,
roomKey?: string,
) {
if (!roomKey) {
return context.roomId;
}
return findMatrixQaProvisionedRoom(context.topology, roomKey).roomId;
}

View file

@ -0,0 +1,136 @@
export const MATRIX_QA_IMAGE_ATTACHMENT_FILENAME = "red-top-blue-bottom.png";
export type MatrixQaMediaTypeCoverageCase = {
contentType: string;
createBuffer: () => Buffer;
expectedAttachmentKind: "audio" | "file" | "image" | "video";
expectedMsgtype: "m.audio" | "m.file" | "m.image" | "m.video";
fileName: string;
kind: "audio" | "file" | "image" | "video";
label: string;
tokenPrefix: string;
};
const MATRIX_QA_IMAGE_COLOR_GROUPS = [["red"], ["blue"]] as const;
const MATRIX_QA_SPLIT_COLOR_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHElEQVR4nGP4z8DwnxLMMGrAsDCAQv2jBgwPAwAxtf4Q24P5oAAAAABJRU5ErkJggg==";
const MATRIX_QA_ONE_PIXEL_JPEG_BASE64 =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBUQEBAVFRUVFRUVFRUVFRUVFRUVFRUXFhUVFRUYHSggGBolHRUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGi0fHyUtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAQMBIgACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAAAAQID/8QAFhEBAQEAAAAAAAAAAAAAAAAAAAER/9oADAMBAAIQAxAAAAH2AP/EABgQAQEAAwAAAAAAAAAAAAAAAAEAEQIS/9oACAEBAAEFAk1o7//EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAgBAwEBPwGn/8QAFhEBAQEAAAAAAAAAAAAAAAAAABEB/9oACAECAQE/AYf/xAAaEAACAgMAAAAAAAAAAAAAAAABEQAhMUFh/9oACAEBAAY/AjK9cY2f/8QAGhABAQACAwAAAAAAAAAAAAAAAAERITFBUf/aAAgBAQABPyGQk7W5jVYkA//Z";
export function createMatrixQaSplitColorImagePng() {
return Buffer.from(MATRIX_QA_SPLIT_COLOR_PNG_BASE64, "base64");
}
function createMatrixQaOnePixelJpeg() {
return Buffer.from(MATRIX_QA_ONE_PIXEL_JPEG_BASE64, "base64");
}
function createMatrixQaPdfFixture() {
return Buffer.from(
[
"%PDF-1.4",
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj",
"2 0 obj << /Type /Pages /Count 0 >> endobj",
"trailer << /Root 1 0 R >>",
"%%EOF",
].join("\n"),
"utf8",
);
}
function createMatrixQaEpubFixture() {
return Buffer.from("PK\u0003\u0004mimetypeapplication/epub+zip\n", "utf8");
}
function createMatrixQaWavFixture() {
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(8_000, 24);
header.writeUInt32LE(16_000, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(0, 40);
return header;
}
function createMatrixQaMp4Fixture() {
return Buffer.from([
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x00,
0x69, 0x73, 0x6f, 0x6d, 0x6d, 0x70, 0x34, 0x31,
]);
}
export const MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES: MatrixQaMediaTypeCoverageCase[] = [
{
contentType: "image/jpeg",
createBuffer: createMatrixQaOnePixelJpeg,
expectedAttachmentKind: "image",
expectedMsgtype: "m.image",
fileName: "matrix-qa-one-pixel.jpg",
kind: "image",
label: "jpeg image",
tokenPrefix: "MATRIX_QA_MEDIA_JPEG",
},
{
contentType: "application/pdf",
createBuffer: createMatrixQaPdfFixture,
expectedAttachmentKind: "file",
expectedMsgtype: "m.file",
fileName: "matrix-qa-document.pdf",
kind: "file",
label: "pdf file",
tokenPrefix: "MATRIX_QA_MEDIA_PDF",
},
{
contentType: "application/epub+zip",
createBuffer: createMatrixQaEpubFixture,
expectedAttachmentKind: "file",
expectedMsgtype: "m.file",
fileName: "matrix-qa-book.epub",
kind: "file",
label: "epub file",
tokenPrefix: "MATRIX_QA_MEDIA_EPUB",
},
{
contentType: "audio/wav",
createBuffer: createMatrixQaWavFixture,
expectedAttachmentKind: "audio",
expectedMsgtype: "m.audio",
fileName: "matrix-qa-audio.wav",
kind: "audio",
label: "wav audio",
tokenPrefix: "MATRIX_QA_MEDIA_AUDIO",
},
{
contentType: "video/mp4",
createBuffer: createMatrixQaMp4Fixture,
expectedAttachmentKind: "video",
expectedMsgtype: "m.video",
fileName: "matrix-qa-video.mp4",
kind: "video",
label: "mp4 video",
tokenPrefix: "MATRIX_QA_MEDIA_VIDEO",
},
];
export function buildMatrixQaImageUnderstandingPrompt(sutUserId: string) {
return `${sutUserId} Image understanding check: describe the top and bottom colors in the attached image in one short sentence.`;
}
export function buildMatrixQaImageGenerationPrompt(sutUserId: string) {
return `${sutUserId} Image generation check: generate a QA lighthouse image and summarize it in one short sentence.`;
}
export function hasMatrixQaExpectedColorReply(body: string | undefined) {
const normalizedBody = body?.toLowerCase() ?? "";
return MATRIX_QA_IMAGE_COLOR_GROUPS.every((group) =>
group.some((color) => normalizedBody.includes(color)),
);
}

View file

@ -0,0 +1,225 @@
import { randomUUID } from "node:crypto";
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
resolveMatrixQaScenarioRoomId,
} from "./scenario-catalog.js";
import {
assertThreadReplyArtifact,
assertTopLevelReplyArtifact,
buildExactMarkerPrompt,
buildMatrixNoticeArtifact,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
createMatrixQaScenarioClient,
NO_REPLY_WINDOW_MS,
advanceMatrixQaActorCursor,
runConfigurableTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
async function runDmSharedSessionFlow(params: {
context: MatrixQaScenarioContext;
expectNotice: boolean;
}) {
const firstRoomId = resolveMatrixQaScenarioRoomId(params.context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
const secondRoomId = resolveMatrixQaScenarioRoomId(
params.context,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
);
const firstResult = await runConfigurableTopLevelScenario({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
roomId: firstRoomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: "MATRIX_QA_DM_PRIMARY",
withMention: false,
});
assertTopLevelReplyArtifact("primary DM reply", firstResult.reply);
const replyClient = createMatrixQaScenarioClient({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
});
const noticeClient = createMatrixQaScenarioClient({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
});
const [replySince, noticeSince] = await Promise.all([
replyClient.primeRoom(),
noticeClient.primeRoom(),
]);
if (!replySince || !noticeSince) {
throw new Error("Matrix DM session scenario could not prime room cursors");
}
const secondToken = `MATRIX_QA_DM_SECONDARY_${randomUUID().slice(0, 8).toUpperCase()}`;
const secondBody = buildExactMarkerPrompt(secondToken);
const secondDriverEventId = await replyClient.sendTextMessage({
body: secondBody,
roomId: secondRoomId,
});
const [replyResult, noticeResult] = await Promise.all([
replyClient.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === secondRoomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message" &&
event.kind === "message" &&
(event.body ?? "").includes(secondToken),
roomId: secondRoomId,
since: replySince,
timeoutMs: params.context.timeoutMs,
}),
noticeClient.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === secondRoomId &&
event.sender === params.context.sutUserId &&
event.kind === "notice" &&
typeof event.body === "string" &&
event.body.includes("channels.matrix.dm.sessionScope"),
roomId: secondRoomId,
since: noticeSince,
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, params.context.timeoutMs),
}),
]);
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.context.syncState,
nextSince: replyResult.since,
startSince: replySince,
});
const secondReply = buildMatrixReplyArtifact(replyResult.event, secondToken);
assertTopLevelReplyArtifact("secondary DM reply", secondReply);
const noticeArtifact = noticeResult.matched
? buildMatrixNoticeArtifact(noticeResult.event)
: undefined;
if (params.expectNotice && !noticeArtifact) {
throw new Error(
"Matrix shared DM session scenario did not emit the expected cross-room notice",
);
}
if (!params.expectNotice && noticeArtifact) {
throw new Error(
"Matrix per-room DM session scenario unexpectedly emitted a shared-session notice",
);
}
return {
firstRoomId,
noticeArtifact,
secondBody,
secondDriverEventId,
secondReply,
secondRoomId,
secondToken,
};
}
export async function runDmThreadReplyOverrideScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
const result = await runConfigurableTopLevelScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
replyPredicate: (event, params) =>
event.relatesTo?.relType === "m.thread" && event.relatesTo?.eventId === params.driverEventId,
roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
timeoutMs: context.timeoutMs,
tokenPrefix: "MATRIX_QA_DM_THREAD",
withMention: false,
});
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.driverEventId,
label: "DM thread override reply",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
roomKey: MATRIX_QA_DRIVER_DM_ROOM_KEY,
token: result.token,
triggerBody: result.body,
},
details: [
`room key: ${MATRIX_QA_DRIVER_DM_ROOM_KEY}`,
`room id: ${roomId}`,
`driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runDmSharedSessionNoticeScenario(context: MatrixQaScenarioContext) {
const result = await runDmSharedSessionFlow({
context,
expectNotice: true,
});
return {
artifacts: {
driverEventId: result.secondDriverEventId,
noticeBodyPreview: result.noticeArtifact?.bodyPreview,
noticeEventId: result.noticeArtifact?.eventId,
reply: result.secondReply,
roomKey: MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
token: result.secondToken,
triggerBody: result.secondBody,
},
details: [
`primary room id: ${result.firstRoomId}`,
`secondary room id: ${result.secondRoomId}`,
`secondary driver event: ${result.secondDriverEventId}`,
`notice event: ${result.noticeArtifact?.eventId ?? "<none>"}`,
`notice preview: ${result.noticeArtifact?.bodyPreview ?? "<none>"}`,
...buildMatrixReplyDetails("secondary reply", result.secondReply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runDmPerRoomSessionOverrideScenario(context: MatrixQaScenarioContext) {
const result = await runDmSharedSessionFlow({
context,
expectNotice: false,
});
return {
artifacts: {
driverEventId: result.secondDriverEventId,
reply: result.secondReply,
roomKey: MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
token: result.secondToken,
triggerBody: result.secondBody,
},
details: [
`primary room id: ${result.firstRoomId}`,
`secondary room id: ${result.secondRoomId}`,
`secondary driver event: ${result.secondDriverEventId}`,
"shared-session notice: suppressed",
...buildMatrixReplyDetails("secondary reply", result.secondReply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
import { randomUUID } from "node:crypto";
import {
assertNoSutReplyWindow,
buildExactMarkerPrompt,
buildMatrixReplyDetails,
buildMentionPrompt,
primeMatrixQaDriverScenarioClient,
runAssertedDriverTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export async function runInboundEditIgnoredScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const ignoredToken = `MATRIX_QA_EDIT_IGNORED_SOURCE_${randomUUID().slice(0, 8).toUpperCase()}`;
const editedToken = `MATRIX_QA_EDIT_IGNORED_${randomUUID().slice(0, 8).toUpperCase()}`;
const rootEventId = await client.sendTextMessage({
body: buildExactMarkerPrompt(ignoredToken),
roomId: context.roomId,
});
const editEventId = await client.sendReplacementMessage({
body: buildMentionPrompt(context.sutUserId, editedToken),
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
targetEventId: rootEventId,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId: context.roomId,
since: startSince,
startSince,
unexpectedMessage: "unexpected SUT reply after Matrix edit-to-mention event",
});
return {
artifacts: {
editEventId,
editedToken,
expectedNoReplyWindowMs: noReplyWindowMs,
rootEventId,
},
details: [
`root event: ${rootEventId}`,
`edit event: ${editEventId}`,
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runInboundEditNoDuplicateTriggerScenario(context: MatrixQaScenarioContext) {
const first = await runAssertedDriverTopLevelScenario({
context,
label: "pre-edit reply",
tokenPrefix: "MATRIX_QA_EDIT_ORIGINAL",
});
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const editedToken = `MATRIX_QA_EDIT_DUPLICATE_${randomUUID().slice(0, 8).toUpperCase()}`;
const editEventId = await client.sendReplacementMessage({
body: buildMentionPrompt(context.sutUserId, editedToken),
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
targetEventId: first.driverEventId,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId: context.roomId,
since: startSince,
startSince,
unexpectedMessage: "unexpected duplicate SUT reply after Matrix edit",
});
return {
artifacts: {
editEventId,
editedToken,
expectedNoReplyWindowMs: noReplyWindowMs,
originalDriverEventId: first.driverEventId,
originalReply: first.reply,
originalToken: first.token,
},
details: [
`original driver event: ${first.driverEventId}`,
...buildMatrixReplyDetails("original reply", first.reply),
`edit event: ${editEventId}`,
`waited ${noReplyWindowMs}ms with no duplicate SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,368 @@
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { MATRIX_QA_MEDIA_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import {
buildMatrixQaImageGenerationPrompt,
buildMatrixQaImageUnderstandingPrompt,
createMatrixQaSplitColorImagePng,
hasMatrixQaExpectedColorReply,
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES,
} from "./scenario-media-fixtures.js";
import {
advanceMatrixQaActorCursor,
assertNoSutReplyWindow,
buildMatrixQaToken,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
isMatrixQaExactMarkerReply,
isMatrixQaMessageLikeKind,
primeMatrixQaActorCursor,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
function requireMatrixQaImageAttachment(event: MatrixQaObservedEvent, scenarioLabel: string) {
if (event.msgtype !== "m.image" || event.attachment?.kind !== "image") {
throw new Error(
`${scenarioLabel} expected an m.image attachment but saw ${event.msgtype ?? "<none>"}`,
);
}
return event.attachment;
}
function buildMatrixQaAttachmentDetailLines(params: {
attachmentEvent: MatrixQaObservedEvent;
label: string;
}) {
return [
`${params.label} event: ${params.attachmentEvent.eventId}`,
`${params.label} msgtype: ${params.attachmentEvent.msgtype ?? "<none>"}`,
`${params.label} attachment kind: ${params.attachmentEvent.attachment?.kind ?? "<none>"}`,
`${params.label} attachment filename: ${params.attachmentEvent.attachment?.filename ?? "<none>"}`,
`${params.label} body preview: ${params.attachmentEvent.body?.slice(0, 200) ?? "<none>"}`,
];
}
async function primeMatrixQaDriverMediaClient(context: MatrixQaScenarioContext) {
return await primeMatrixQaActorCursor({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
function buildMatrixQaMediaTypeCoveragePrompt(params: {
label: string;
sutUserId: string;
token: string;
}) {
return `${params.sutUserId} Matrix media type coverage (${params.label}): ignore the attachment content and reply with only this exact marker: ${params.token}`;
}
export async function runImageUnderstandingAttachmentScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const triggerBody = buildMatrixQaImageUnderstandingPrompt(context.sutUserId);
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: createMatrixQaSplitColorImagePng(),
contentType: "image/png",
fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
kind: "image",
mentionUserIds: [context.sutUserId],
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.attachment?.kind === "image" &&
event.attachment.caption === triggerBody,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.relatesTo === undefined &&
isMatrixQaMessageLikeKind(event.kind) &&
hasMatrixQaExpectedColorReply(event.body),
roomId,
since: attachmentEvent.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event);
return {
artifacts: {
attachmentCaptionPreview: attachmentEvent.event.attachment?.caption?.slice(0, 200),
attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
driverEventId,
reply,
roomId,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver attachment event: ${driverEventId}`,
`sent attachment filename: ${MATRIX_QA_IMAGE_ATTACHMENT_FILENAME}`,
`sent attachment caption: ${attachmentEvent.event.attachment?.caption ?? "<none>"}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runMediaTypeCoverageScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const attachments: NonNullable<MatrixQaScenarioExecution["artifacts"]>["attachments"] = [];
const replies: NonNullable<MatrixQaScenarioExecution["artifacts"]>["replies"] = [];
const details = [`room id: ${roomId}`];
let since = startSince;
for (const mediaCase of MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES) {
const token = buildMatrixQaToken(mediaCase.tokenPrefix);
const triggerBody = buildMatrixQaMediaTypeCoveragePrompt({
label: mediaCase.label,
sutUserId: context.sutUserId,
token,
});
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: mediaCase.createBuffer(),
contentType: mediaCase.contentType,
fileName: mediaCase.fileName,
kind: mediaCase.kind,
mentionUserIds: [context.sutUserId],
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.msgtype === mediaCase.expectedMsgtype &&
event.attachment?.kind === mediaCase.expectedAttachmentKind &&
event.attachment.filename === mediaCase.fileName &&
event.attachment.caption === triggerBody,
roomId,
since,
timeoutMs: context.timeoutMs,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token,
}) && event.relatesTo === undefined,
roomId,
since: attachmentEvent.since,
timeoutMs: context.timeoutMs,
});
since = matched.since ?? since;
const reply = buildMatrixReplyArtifact(matched.event, token);
attachments.push({
eventId: driverEventId,
filename: mediaCase.fileName,
kind: attachmentEvent.event.attachment?.kind,
label: mediaCase.label,
msgtype: attachmentEvent.event.msgtype,
});
replies.push({
eventId: reply.eventId,
label: mediaCase.label,
token,
tokenMatched: reply.tokenMatched,
});
details.push(
`${mediaCase.label} event: ${driverEventId}`,
`${mediaCase.label} msgtype: ${attachmentEvent.event.msgtype ?? "<none>"}`,
`${mediaCase.label} attachment kind: ${attachmentEvent.event.attachment?.kind ?? "<none>"}`,
`${mediaCase.label} attachment filename: ${attachmentEvent.event.attachment?.filename ?? "<none>"}`,
...buildMatrixReplyDetails(`${mediaCase.label} reply`, reply),
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: since,
startSince,
});
return {
artifacts: {
attachments,
replies,
roomId,
},
details: details.join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runAttachmentOnlyIgnoredScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const driverEventId = await client.sendMediaMessage({
buffer: createMatrixQaSplitColorImagePng(),
contentType: "image/png",
fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
kind: "image",
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.attachment?.kind === "image" &&
event.attachment.caption === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId,
since: attachmentEvent.since,
startSince,
unexpectedMessage: "unexpected SUT reply to attachment-only group media",
});
return {
artifacts: {
attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
driverEventId,
expectedNoReplyWindowMs: noReplyWindowMs,
roomId,
},
details: [
`room id: ${roomId}`,
`driver attachment event: ${driverEventId}`,
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runUnsupportedMediaSafeScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const token = buildMatrixQaToken("MATRIX_QA_UNSUPPORTED_MEDIA");
const triggerBody = `${context.sutUserId} Unsupported media QA check: ignore the attached text file and reply with only this exact marker: ${token}`;
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: Buffer.from("unsupported Matrix QA attachment body\n", "utf8"),
contentType: "text/plain",
fileName: "unsupported-matrix-qa.txt",
kind: "file",
mentionUserIds: [context.sutUserId],
roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event, token);
return {
artifacts: {
attachmentFilename: "unsupported-matrix-qa.txt",
attachmentKind: "file",
driverEventId,
reply,
roomId,
token,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver file event: ${driverEventId}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runGeneratedImageDeliveryScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const triggerBody = buildMatrixQaImageGenerationPrompt(context.sutUserId);
const driverEventId = await client.sendTextMessage({
body: triggerBody,
mentionUserIds: [context.sutUserId],
roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.relatesTo === undefined &&
event.msgtype === "m.image" &&
event.attachment?.kind === "image",
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const attachment = requireMatrixQaImageAttachment(
matched.event,
"Matrix generated image delivery scenario",
);
return {
artifacts: {
attachmentBodyPreview: matched.event.body?.slice(0, 200),
attachmentEventId: matched.event.eventId,
attachmentFilename: attachment.filename,
attachmentKind: attachment.kind,
attachmentMsgtype: matched.event.msgtype,
driverEventId,
roomId,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver event: ${driverEventId}`,
...buildMatrixQaAttachmentDetailLines({
attachmentEvent: matched.event,
label: "generated image",
}),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,239 @@
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import {
advanceMatrixQaActorCursor,
assertNoSutReplyWindow,
createMatrixQaDriverScenarioClient,
primeMatrixQaActorCursor,
type MatrixQaActorId,
type MatrixQaScenarioContext,
type MatrixQaSyncState,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export function buildMatrixQaReactionDetailLines(params: {
actorUserId?: string;
observedReactionKey?: string;
reactionEmoji: string;
reactionEventId: string;
reactionTargetEventId: string;
}) {
return [
`reaction event: ${params.reactionEventId}`,
`reaction target: ${params.reactionTargetEventId}`,
`reaction emoji: ${params.reactionEmoji}`,
...(params.actorUserId ? [`reaction sender: ${params.actorUserId}`] : []),
...(params.observedReactionKey ? [`observed reaction key: ${params.observedReactionKey}`] : []),
];
}
function requireMatrixQaReactionTargetEventId(
reactionTargetEventId: string | undefined,
scenarioLabel: string,
) {
const normalizedReactionTargetEventId = reactionTargetEventId?.trim();
if (!normalizedReactionTargetEventId) {
throw new Error(`${scenarioLabel} requires a canary reply event id`);
}
return normalizedReactionTargetEventId;
}
export async function observeReactionScenario(params: {
actorId: MatrixQaActorId;
actorUserId: string;
accessToken: string;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
reactionEmoji?: string;
reactionTargetEventId: string;
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaScenarioContext["syncStreams"];
timeoutMs: number;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const reactionEmoji = params.reactionEmoji ?? "👍";
const reactionEventId = await client.sendReaction({
emoji: reactionEmoji,
messageId: params.reactionTargetEventId,
roomId: params.roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.actorUserId &&
event.type === "m.reaction" &&
event.eventId === reactionEventId &&
event.reaction?.eventId === params.reactionTargetEventId &&
event.reaction?.key === reactionEmoji,
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
return {
actorId: params.actorId,
actorUserId: params.actorUserId,
event: matched.event,
reactionEmoji,
reactionEventId,
reactionTargetEventId: params.reactionTargetEventId,
since: matched.since,
startSince,
};
}
export function buildMatrixQaReactionArtifacts(params: {
actorUserId?: string;
expectedNoReplyWindowMs?: number;
reaction: Awaited<ReturnType<typeof observeReactionScenario>>;
}) {
return {
...(params.actorUserId ? { actorUserId: params.actorUserId } : {}),
...(params.expectedNoReplyWindowMs === undefined
? {}
: { expectedNoReplyWindowMs: params.expectedNoReplyWindowMs }),
reactionEmoji: params.reaction.reactionEmoji,
reactionEventId: params.reaction.reactionEventId,
reactionTargetEventId: params.reaction.reactionTargetEventId,
};
}
export async function runReactionNotificationScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction scenario",
);
const result = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
return {
artifacts: buildMatrixQaReactionArtifacts({ reaction: result }),
details: buildMatrixQaReactionDetailLines({
actorUserId: result.actorUserId,
observedReactionKey: result.event.reaction?.key,
reactionEmoji: result.reactionEmoji,
reactionEventId: result.reactionEventId,
reactionTargetEventId: result.reactionTargetEventId,
}).join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runReactionNotAReplyScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction no-reply scenario",
);
const reaction = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const client = createMatrixQaDriverScenarioClient(context);
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: reaction.actorId,
client,
context,
roomId: context.roomId,
since: reaction.since,
startSince: reaction.startSince,
unexpectedLines: [
`reaction target: ${reaction.reactionTargetEventId}`,
`reaction event: ${reaction.reactionEventId}`,
],
unexpectedMessage: `unexpected SUT reply after reaction from ${context.driverUserId}`,
});
return {
artifacts: buildMatrixQaReactionArtifacts({
actorUserId: context.driverUserId,
expectedNoReplyWindowMs: noReplyWindowMs,
reaction,
}),
details: [
...buildMatrixQaReactionDetailLines({
reactionEmoji: reaction.reactionEmoji,
reactionEventId: reaction.reactionEventId,
reactionTargetEventId: reaction.reactionTargetEventId,
}),
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runReactionRedactionObservedScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction redaction scenario",
);
const reaction = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const client = createMatrixQaDriverScenarioClient(context);
const redactionEventId = await client.redactEvent({
eventId: reaction.reactionEventId,
reason: "matrix qa reaction removal",
roomId: context.roomId,
});
const redaction = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === context.roomId &&
event.eventId === redactionEventId &&
event.sender === context.driverUserId &&
event.kind === "redaction",
roomId: context.roomId,
since: reaction.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: reaction.actorId,
syncState: context.syncState,
nextSince: redaction.since,
startSince: reaction.startSince,
});
return {
artifacts: {
...buildMatrixQaReactionArtifacts({ reaction }),
redactionEventId,
},
details: [
...buildMatrixQaReactionDetailLines({
reactionEmoji: reaction.reactionEmoji,
reactionEventId: reaction.reactionEventId,
reactionTargetEventId: reaction.reactionTargetEventId,
}),
`redaction event: ${redactionEventId}`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,420 @@
import {
MATRIX_QA_HOMESERVER_ROOM_KEY,
MATRIX_QA_RESTART_ROOM_KEY,
resolveMatrixQaScenarioRoomId,
} from "./scenario-catalog.js";
import {
buildMatrixReplyDetails,
buildMatrixQaToken,
buildMentionPrompt,
buildMatrixReplyArtifact,
isMatrixQaExactMarkerReply,
assertTopLevelReplyArtifact,
advanceMatrixQaActorCursor,
NO_REPLY_WINDOW_MS,
primeMatrixQaDriverScenarioClient,
runAssertedDriverTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import {
rewriteMatrixSyncStoreCursor,
waitForMatrixInboundDedupeEntry,
waitForMatrixSyncStoreWithCursor,
} from "./scenario-runtime-state-files.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export async function runHomeserverRestartResumeScenario(context: MatrixQaScenarioContext) {
if (!context.interruptTransport) {
throw new Error("Matrix homeserver restart scenario requires a transport interruption hook");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_HOMESERVER_ROOM_KEY);
await context.interruptTransport();
const resumed = await runAssertedDriverTopLevelScenario({
context,
label: "post-homeserver-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_HOMESERVER",
});
return {
artifacts: {
driverEventId: resumed.driverEventId,
reply: resumed.reply,
roomId,
token: resumed.token,
transportInterruption: "homeserver-restart",
},
details: [
`room id: ${roomId}`,
"transport interruption: homeserver-restart",
`driver event: ${resumed.driverEventId}`,
...buildMatrixReplyDetails("reply", resumed.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runRestartResumeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix restart scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
await context.restartGateway();
const result = await runAssertedDriverTopLevelScenario({
context,
label: "post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
restartSignal: "SIGUSR1",
roomId,
token: result.token,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`post-restart driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runPostRestartRoomContinueScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix post-restart continuity scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
await context.restartGateway();
const first = await runAssertedDriverTopLevelScenario({
context,
label: "first post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART_FIRST",
});
const second = await runAssertedDriverTopLevelScenario({
context,
label: "second post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART_SECOND",
});
return {
artifacts: {
firstDriverEventId: first.driverEventId,
firstReply: first.reply,
firstToken: first.token,
restartSignal: "SIGUSR1",
roomId,
secondDriverEventId: second.driverEventId,
secondReply: second.reply,
secondToken: second.token,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`first post-restart driver event: ${first.driverEventId}`,
...buildMatrixReplyDetails("first reply", first.reply),
`second post-restart driver event: ${second.driverEventId}`,
...buildMatrixReplyDetails("second reply", second.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runInitialCatchupThenIncrementalScenario(context: MatrixQaScenarioContext) {
if (!context.restartGatewayWithQueuedMessage) {
throw new Error(
"Matrix initial catchup scenario requires a queued-message gateway restart callback",
);
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const catchupToken = buildMatrixQaToken("MATRIX_QA_CATCHUP");
const catchupBody = buildMentionPrompt(context.sutUserId, catchupToken);
let catchupDriverEventId = "";
await context.restartGatewayWithQueuedMessage(async () => {
catchupDriverEventId = await client.sendTextMessage({
body: catchupBody,
mentionUserIds: [context.sutUserId],
roomId,
});
});
const catchupMatched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: catchupToken,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: catchupMatched.since,
startSince,
});
const catchupReply = buildMatrixReplyArtifact(catchupMatched.event, catchupToken);
assertTopLevelReplyArtifact("catchup reply", catchupReply);
const incremental = await runAssertedDriverTopLevelScenario({
context,
label: "incremental reply after catchup",
roomId,
tokenPrefix: "MATRIX_QA_INCREMENTAL",
});
return {
artifacts: {
catchupDriverEventId,
catchupReply,
catchupToken,
incrementalDriverEventId: incremental.driverEventId,
incrementalReply: incremental.reply,
incrementalToken: incremental.token,
restartSignal: "SIGUSR1",
roomId,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`catchup driver event: ${catchupDriverEventId}`,
...buildMatrixReplyDetails("catchup reply", catchupReply),
`incremental driver event: ${incremental.driverEventId}`,
...buildMatrixReplyDetails("incremental reply", incremental.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runRestartReplayDedupeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix restart replay dedupe scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const replayToken = buildMatrixQaToken("MATRIX_QA_REPLAY_DEDUPE");
const replayBody = buildMentionPrompt(context.sutUserId, replayToken);
const replayDriverEventId = await client.sendTextMessage({
body: replayBody,
mentionUserIds: [context.sutUserId],
roomId,
});
const firstMatched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: replayToken,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: firstMatched.since,
startSince,
});
const firstReply = buildMatrixReplyArtifact(firstMatched.event, replayToken);
assertTopLevelReplyArtifact("first replay-dedupe reply", firstReply);
await context.restartGateway();
const duplicate = await client.waitForOptionalRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.eventId !== firstReply.eventId &&
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: replayToken,
}),
roomId,
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
});
if (duplicate.matched) {
throw new Error(
[
"Matrix restart replayed an already handled event",
`original driver event: ${replayDriverEventId}`,
...buildMatrixReplyDetails("first reply", firstReply),
...buildMatrixReplyDetails(
"duplicate reply",
buildMatrixReplyArtifact(duplicate.event, replayToken),
),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: duplicate.since,
startSince: firstMatched.since ?? startSince,
});
const postRestart = await runAssertedDriverTopLevelScenario({
context,
label: "fresh post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_REPLAY_DEDUPE_FRESH",
});
return {
artifacts: {
duplicateWindowMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
firstDriverEventId: replayDriverEventId,
firstReply,
firstToken: replayToken,
freshDriverEventId: postRestart.driverEventId,
freshReply: postRestart.reply,
freshToken: postRestart.token,
restartSignal: "SIGUSR1",
roomId,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`first driver event: ${replayDriverEventId}`,
...buildMatrixReplyDetails("first reply", firstReply),
`duplicate replay window: ${Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs)}ms`,
`fresh post-restart driver event: ${postRestart.driverEventId}`,
...buildMatrixReplyDetails("fresh reply", postRestart.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runStaleSyncReplayDedupeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGatewayAfterStateMutation) {
throw new Error(
"Matrix stale sync replay dedupe scenario requires a persisted-state restart callback",
);
}
if (!context.gatewayStateDir) {
throw new Error("Matrix stale sync replay dedupe scenario requires a gateway state directory");
}
const stateDir = context.gatewayStateDir;
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const syncStore = await waitForMatrixSyncStoreWithCursor({
context,
stateDir,
timeoutMs: Math.min(5_000, context.timeoutMs),
});
const staleCursor = syncStore.cursor;
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const replayToken = buildMatrixQaToken("MATRIX_QA_STALE_SYNC_DEDUPE");
const replayBody = buildMentionPrompt(context.sutUserId, replayToken);
const replayDriverEventId = await client.sendTextMessage({
body: replayBody,
mentionUserIds: [context.sutUserId],
roomId,
});
const firstMatched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: replayToken,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: firstMatched.since,
startSince,
});
const firstReply = buildMatrixReplyArtifact(firstMatched.event, replayToken);
assertTopLevelReplyArtifact("first stale-sync replay-dedupe reply", firstReply);
await waitForMatrixInboundDedupeEntry({
context,
eventId: replayDriverEventId,
roomId,
stateDir,
timeoutMs: Math.min(5_000, context.timeoutMs),
});
await context.restartGatewayAfterStateMutation(async () => {
await rewriteMatrixSyncStoreCursor({
cursor: staleCursor,
pathname: syncStore.pathname,
});
});
const duplicate = await client.waitForOptionalRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.eventId !== firstReply.eventId &&
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: replayToken,
}),
roomId,
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
});
if (duplicate.matched) {
throw new Error(
[
"Matrix stale sync cursor replayed an already handled event",
`original driver event: ${replayDriverEventId}`,
`stale sync cursor: ${staleCursor}`,
...buildMatrixReplyDetails("first reply", firstReply),
...buildMatrixReplyDetails(
"duplicate reply",
buildMatrixReplyArtifact(duplicate.event, replayToken),
),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: duplicate.since,
startSince: firstMatched.since ?? startSince,
});
const postRestart = await runAssertedDriverTopLevelScenario({
context,
label: "fresh post-stale-sync-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_STALE_SYNC_DEDUPE_FRESH",
});
return {
artifacts: {
dedupeCommitObserved: true,
duplicateWindowMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
firstDriverEventId: replayDriverEventId,
firstReply,
firstToken: replayToken,
freshDriverEventId: postRestart.driverEventId,
freshReply: postRestart.reply,
freshToken: postRestart.token,
restartSignal: "hard-restart",
roomId,
staleSyncCursor: staleCursor,
},
details: [
`room id: ${roomId}`,
"restart signal: hard-restart",
`stale sync cursor: ${staleCursor}`,
`first driver event: ${replayDriverEventId}`,
...buildMatrixReplyDetails("first reply", firstReply),
`duplicate replay window: ${Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs)}ms`,
`fresh post-restart driver event: ${postRestart.driverEventId}`,
...buildMatrixReplyDetails("fresh reply", postRestart.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,877 @@
import { randomUUID } from "node:crypto";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import {
MATRIX_QA_BLOCK_ROOM_KEY,
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
resolveMatrixQaScenarioRoomId,
} from "./scenario-catalog.js";
import {
buildMatrixQaReactionArtifacts,
buildMatrixQaReactionDetailLines,
observeReactionScenario,
} from "./scenario-runtime-reaction.js";
import {
assertThreadReplyArtifact,
assertTopLevelReplyArtifact,
advanceMatrixQaActorCursor,
buildMatrixBlockStreamingPrompt,
buildMatrixQuietStreamingPrompt,
buildMatrixQaToken,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
createMatrixQaDriverScenarioClient,
createMatrixQaScenarioClient,
isMatrixQaExactMarkerReply,
isMatrixQaMessageLikeKind,
NO_REPLY_WINDOW_MS,
primeMatrixQaActorCursor,
primeMatrixQaDriverScenarioClient,
runAssertedDriverTopLevelScenario,
runConfigurableTopLevelScenario,
runDriverTopLevelMentionScenario,
runNoReplyExpectedScenario,
runTopologyScopedTopLevelScenario,
waitForMembershipEvent,
type MatrixQaScenarioContext,
type MatrixQaSyncState,
} from "./scenario-runtime-shared.js";
import type { MatrixQaCanaryArtifact, MatrixQaScenarioExecution } from "./scenario-types.js";
type MatrixQaThreadScenarioResult = Awaited<ReturnType<typeof runThreadScenario>>;
const MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE =
/thread=true is unavailable because no channel plugin registered subagent_spawning hooks/i;
const MATRIX_QA_HOT_RELOAD_RESTART_DELAY_MS = 300_000;
function assertMatrixQaInReplyTarget(params: {
actualEventId?: string;
expectedEventId: string;
label: string;
}) {
if (params.actualEventId !== params.expectedEventId) {
throw new Error(
`${params.label} targeted ${params.actualEventId ?? "<none>"} instead of ${params.expectedEventId}`,
);
}
}
function requireMatrixQaNestedThreadEvent(
nestedDriverEventId: string | undefined,
scenarioLabel: string,
) {
if (!nestedDriverEventId) {
throw new Error(`${scenarioLabel} did not create a nested trigger`);
}
return nestedDriverEventId;
}
function buildMatrixQaThreadArtifacts(result: MatrixQaThreadScenarioResult) {
return {
driverEventId: result.driverEventId,
reply: result.reply,
rootEventId: result.rootEventId,
token: result.token,
};
}
function failIfMatrixSubagentThreadHookError(event: MatrixQaObservedEvent) {
if (MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE.test(event.body ?? "")) {
throw new Error(
`Matrix subagent thread spawn hit missing hook error: ${event.body ?? "<empty>"}`,
);
}
}
function buildMatrixQaThreadDetailLines(params: {
result: MatrixQaThreadScenarioResult;
includeNestedTrigger?: boolean;
extraLines?: string[];
replyLabel?: string;
}) {
return [
`thread root event: ${params.result.rootEventId}`,
...(params.includeNestedTrigger && params.result.nestedDriverEventId
? [`nested trigger event: ${params.result.nestedDriverEventId}`]
: []),
`mention trigger event: ${params.result.driverEventId}`,
...(params.extraLines ?? []),
...buildMatrixReplyDetails(params.replyLabel ?? "reply", params.result.reply),
];
}
async function runThreadScenario(
params: MatrixQaScenarioContext,
options?: {
createNestedReply?: boolean;
tokenPrefix?: string;
},
) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(params);
const rootBody = `thread root ${randomUUID().slice(0, 8)}`;
const rootEventId = await client.sendTextMessage({
body: rootBody,
roomId: params.roomId,
});
const nestedDriverEventId =
options?.createNestedReply === true
? await client.sendTextMessage({
body: `thread nested ${randomUUID().slice(0, 8)}`,
replyToEventId: rootEventId,
roomId: params.roomId,
threadRootEventId: rootEventId,
})
: undefined;
const triggerEventId = nestedDriverEventId ?? rootEventId;
const token = buildMatrixQaToken(options?.tokenPrefix ?? "MATRIX_QA_THREAD");
const driverEventId = await client.sendTextMessage({
body: buildMentionPrompt(params.sutUserId, token),
mentionUserIds: [params.sutUserId],
replyToEventId: triggerEventId,
roomId: params.roomId,
threadRootEventId: rootEventId,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: params.roomId,
sutUserId: params.sutUserId,
token,
}) &&
event.relatesTo?.relType === "m.thread" &&
event.relatesTo.eventId === rootEventId,
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.syncState,
nextSince: matched.since,
startSince,
});
return {
driverEventId,
nestedDriverEventId,
reply: buildMatrixReplyArtifact(matched.event, token),
rootEventId,
token,
};
}
export async function runMatrixQaCanary(params: {
baseUrl: string;
driverAccessToken: string;
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaScenarioContext["syncStreams"];
sutUserId: string;
timeoutMs: number;
}): Promise<{
driverEventId: string;
reply: MatrixQaCanaryArtifact["reply"];
token: string;
}> {
const canary = await runDriverTopLevelMentionScenario({
baseUrl: params.baseUrl,
driverAccessToken: params.driverAccessToken,
observedEvents: params.observedEvents,
roomId: params.roomId,
syncState: params.syncState,
syncStreams: params.syncStreams,
sutUserId: params.sutUserId,
timeoutMs: params.timeoutMs,
tokenPrefix: "MATRIX_QA_CANARY",
});
assertTopLevelReplyArtifact("canary reply", canary.reply);
return canary;
}
export async function runThreadFollowUpScenario(context: MatrixQaScenarioContext) {
const result = await runThreadScenario(context);
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.rootEventId,
label: "thread reply",
});
return {
artifacts: buildMatrixQaThreadArtifacts(result),
details: [
`root event: ${result.rootEventId}`,
`driver thread event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runThreadRootPreservationScenario(context: MatrixQaScenarioContext) {
const result = await runThreadScenario(context, {
createNestedReply: true,
tokenPrefix: "MATRIX_QA_THREAD_ROOT",
});
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.rootEventId,
label: "thread root preservation reply",
});
requireMatrixQaNestedThreadEvent(
result.nestedDriverEventId,
"Matrix thread root preservation scenario",
);
return {
artifacts: buildMatrixQaThreadArtifacts(result),
details: buildMatrixQaThreadDetailLines({
result,
includeNestedTrigger: true,
extraLines: [
`reply thread root: ${result.reply.relatesTo?.eventId ?? "<none>"}`,
`reply in_reply_to: ${result.reply.relatesTo?.inReplyToId ?? "<none>"}`,
],
}).join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runThreadNestedReplyShapeScenario(context: MatrixQaScenarioContext) {
const result = await runThreadScenario(context, {
createNestedReply: true,
tokenPrefix: "MATRIX_QA_THREAD_NESTED",
});
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.rootEventId,
label: "thread nested reply",
});
requireMatrixQaNestedThreadEvent(
result.nestedDriverEventId,
"Matrix thread nested reply scenario",
);
assertMatrixQaInReplyTarget({
actualEventId: result.reply.relatesTo?.inReplyToId,
expectedEventId: result.rootEventId,
label: "thread nested reply in_reply_to",
});
return {
artifacts: buildMatrixQaThreadArtifacts(result),
details: buildMatrixQaThreadDetailLines({
result,
includeNestedTrigger: true,
extraLines: [
`reply in_reply_to: ${result.reply.relatesTo?.inReplyToId ?? "<none>"}`,
`expected fallback root: ${result.rootEventId}`,
],
}).join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runThreadIsolationScenario(context: MatrixQaScenarioContext) {
const threadPhase = await runThreadScenario(context);
assertThreadReplyArtifact(threadPhase.reply, {
expectedRootEventId: threadPhase.rootEventId,
label: "thread isolation reply",
});
const topLevelPhase = await runAssertedDriverTopLevelScenario({
context,
label: "top-level follow-up reply",
tokenPrefix: "MATRIX_QA_TOPLEVEL",
});
return {
artifacts: {
threadDriverEventId: threadPhase.driverEventId,
threadReply: threadPhase.reply,
threadRootEventId: threadPhase.rootEventId,
threadToken: threadPhase.token,
topLevelDriverEventId: topLevelPhase.driverEventId,
topLevelReply: topLevelPhase.reply,
topLevelToken: topLevelPhase.token,
},
details: [
`thread root event: ${threadPhase.rootEventId}`,
`thread driver event: ${threadPhase.driverEventId}`,
...buildMatrixReplyDetails("thread reply", threadPhase.reply),
`top-level driver event: ${topLevelPhase.driverEventId}`,
...buildMatrixReplyDetails("top-level reply", topLevelPhase.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runSubagentThreadSpawnScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const childToken = buildMatrixQaToken("MATRIX_QA_SUBAGENT_CHILD");
const triggerBody = [
`${context.sutUserId} Use sessions_spawn for this QA check.`,
`task="Reply exactly \`${childToken}\`. This is the marker."`,
"label=matrix-thread-subagent thread=true mode=session runTimeoutSeconds=30",
].join(" ");
const driverEventId = await client.sendTextMessage({
body: triggerBody,
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
});
const intro = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) => {
failIfMatrixSubagentThreadHookError(event);
return (
event.roomId === context.roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
isMatrixQaMessageLikeKind(event.kind) &&
/\bsession active\b/i.test(event.body ?? "") &&
/Messages here go directly to this session/i.test(event.body ?? "")
);
},
roomId: context.roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const completion = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) => {
failIfMatrixSubagentThreadHookError(event);
return (
event.roomId === context.roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
isMatrixQaMessageLikeKind(event.kind) &&
(event.body ?? "").includes(childToken) &&
event.relatesTo?.relType === "m.thread" &&
event.relatesTo.eventId === intro.event.eventId
);
},
roomId: context.roomId,
since: intro.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: completion.since,
startSince,
});
const subagentIntro = buildMatrixReplyArtifact(intro.event);
const subagentCompletion = buildMatrixReplyArtifact(completion.event, childToken);
return {
artifacts: {
driverEventId,
subagentCompletion,
subagentIntro,
threadRootEventId: intro.event.eventId,
threadToken: childToken,
triggerBody,
},
details: [
`driver event: ${driverEventId}`,
`subagent thread root event: ${intro.event.eventId}`,
...buildMatrixReplyDetails("subagent intro", subagentIntro),
...buildMatrixReplyDetails("subagent completion", subagentCompletion),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runTopLevelReplyShapeScenario(context: MatrixQaScenarioContext) {
const result = await runAssertedDriverTopLevelScenario({
context,
label: "top-level reply",
tokenPrefix: "MATRIX_QA_TOPLEVEL",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
token: result.token,
},
details: [
`driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runRoomThreadReplyOverrideScenario(context: MatrixQaScenarioContext) {
const result = await runConfigurableTopLevelScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
replyPredicate: (event, params) =>
event.relatesTo?.relType === "m.thread" && event.relatesTo?.eventId === params.driverEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
timeoutMs: context.timeoutMs,
tokenPrefix: "MATRIX_QA_ROOM_THREAD",
});
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.driverEventId,
label: "room thread override reply",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
token: result.token,
triggerBody: result.body,
},
details: [
`driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runObserverAllowlistOverrideScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: context.observerAccessToken,
actorId: "observer",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
const token = buildMatrixQaToken("MATRIX_QA_OBSERVER_ALLOWLIST");
const body = buildMentionPrompt(context.sutUserId, token);
const driverEventId = await client.sendTextMessage({
body,
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: context.roomId,
sutUserId: context.sutUserId,
token,
}) && event.relatesTo === undefined,
roomId: context.roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "observer",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event, token);
assertTopLevelReplyArtifact("observer allowlist reply", reply);
return {
artifacts: {
actorUserId: context.observerUserId,
driverEventId,
reply,
token,
triggerBody: body,
},
details: [
`trigger sender: ${context.observerUserId}`,
`driver event: ${driverEventId}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runAllowlistHotReloadScenario(context: MatrixQaScenarioContext) {
if (!context.patchGatewayConfig) {
throw new Error("Matrix allowlist hot-reload scenario requires gateway config patching");
}
const accepted = await runTopologyScopedTopLevelScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
context,
roomKey: context.topology.defaultRoomKey,
tokenPrefix: "MATRIX_QA_GROUP_RELOAD_ACCEPTED",
});
const accountId = context.sutAccountId ?? "sut";
await context.patchGatewayConfig(
{
channels: {
matrix: {
accounts: {
[accountId]: {
groupAllowFrom: [context.driverUserId],
},
},
},
},
gateway: {
// Isolate the Matrix handler's per-message config read from generic channel reload.
reload: {
mode: "off",
},
},
},
{
restartDelayMs: MATRIX_QA_HOT_RELOAD_RESTART_DELAY_MS,
},
);
const blockedToken = buildMatrixQaToken("MATRIX_QA_GROUP_RELOAD_REMOVED");
const removed = await runNoReplyExpectedScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
baseUrl: context.baseUrl,
body: buildMentionPrompt(context.sutUserId, blockedToken),
mentionUserIds: [context.sutUserId],
observedEvents: context.observedEvents,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
replyPredicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: context.roomId,
sutUserId: context.sutUserId,
token: blockedToken,
}),
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
token: blockedToken,
});
return {
artifacts: {
accepted: accepted.artifacts ?? {},
blocked: removed.artifacts ?? {},
driverEventId: accepted.artifacts?.driverEventId,
secondDriverEventId: removed.artifacts?.driverEventId,
firstReply: accepted.artifacts?.reply,
token: accepted.artifacts?.token,
triggerBody: accepted.artifacts?.triggerBody,
},
details: [
"group allowlist before removal:",
accepted.details,
"group allowlist after hot reload removal:",
removed.details,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runQuietStreamingPreviewScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const finalText = `MATRIX_QA_QUIET_STREAM_${randomUUID().slice(0, 8).toUpperCase()} preview complete`;
const triggerBody = buildMatrixQuietStreamingPrompt(context.sutUserId, finalText);
const driverEventId = await client.sendTextMessage({
body: triggerBody,
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
});
const preview = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === context.roomId &&
event.sender === context.sutUserId &&
event.kind === "notice",
roomId: context.roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const finalized = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === context.roomId &&
event.sender === context.sutUserId &&
isMatrixQaMessageLikeKind(event.kind) &&
event.relatesTo?.relType === "m.replace" &&
event.relatesTo.eventId === preview.event.eventId &&
event.body === finalText,
roomId: context.roomId,
since: preview.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: finalized.since,
startSince,
});
const finalReply = buildMatrixReplyArtifact(finalized.event, finalText);
return {
artifacts: {
driverEventId,
previewBodyPreview: preview.event.body?.slice(0, 200),
previewEventId: preview.event.eventId,
reply: finalReply,
token: finalText,
triggerBody,
},
details: [
`driver event: ${driverEventId}`,
`preview event: ${preview.event.eventId}`,
`preview kind: ${preview.event.kind}`,
`preview body: ${preview.event.body ?? "<none>"}`,
`final reply relation: ${finalized.event.relatesTo?.relType ?? "<none>"}`,
`final reply target: ${finalized.event.relatesTo?.eventId ?? "<none>"}`,
...buildMatrixReplyDetails("final reply", finalReply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runBlockStreamingScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_BLOCK_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const firstText = `MATRIX_QA_BLOCK_ONE_${randomUUID().slice(0, 8).toUpperCase()}`;
const secondText = `MATRIX_QA_BLOCK_TWO_${randomUUID().slice(0, 8).toUpperCase()}`;
const triggerBody = buildMatrixBlockStreamingPrompt(context.sutUserId, firstText, secondText);
const driverEventId = await client.sendTextMessage({
body: triggerBody,
mentionUserIds: [context.sutUserId],
roomId,
});
const firstBlock = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
isMatrixQaMessageLikeKind(event.kind) &&
(event.body ?? "").includes(firstText) &&
!(event.body ?? "").includes(secondText),
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const secondBlock = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
isMatrixQaMessageLikeKind(event.kind) &&
(event.body ?? "").includes(secondText),
roomId,
since: firstBlock.since,
timeoutMs: context.timeoutMs,
});
if (firstBlock.event.eventId === secondBlock.event.eventId) {
throw new Error(
"Matrix block streaming scenario reused one event instead of preserving blocks",
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: secondBlock.since,
startSince,
});
return {
artifacts: {
blockEventIds: [firstBlock.event.eventId, secondBlock.event.eventId],
driverEventId,
reply: buildMatrixReplyArtifact(secondBlock.event, secondText),
roomId,
token: secondText,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver event: ${driverEventId}`,
`block one event: ${firstBlock.event.eventId}`,
`block two event: ${secondBlock.event.eventId}`,
`block one kind: ${firstBlock.event.kind}`,
`block two kind: ${secondBlock.event.kind}`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runRoomAutoJoinInviteScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const dynamicRoomId = await client.createPrivateRoom({
inviteUserIds: [context.observerUserId, context.sutUserId],
name: `Matrix QA AutoJoin ${randomUUID().slice(0, 8)}`,
});
const joinResult = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === dynamicRoomId &&
event.type === "m.room.member" &&
event.stateKey === context.sutUserId &&
event.membership === "join",
roomId: dynamicRoomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const joinEvent = joinResult.event;
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: joinResult.since,
startSince,
});
const result = await runAssertedDriverTopLevelScenario({
context,
label: "auto-join room reply",
roomId: dynamicRoomId,
tokenPrefix: "MATRIX_QA_AUTOJOIN",
});
return {
artifacts: {
driverEventId: result.driverEventId,
joinedRoomId: dynamicRoomId,
membershipJoinEventId: joinEvent.eventId,
reply: result.reply,
token: result.token,
triggerBody: result.body,
},
details: [
`joined room id: ${dynamicRoomId}`,
`join event: ${joinEvent.eventId}`,
`driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runMembershipLossScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEMBERSHIP_ROOM_KEY);
const driverClient = createMatrixQaDriverScenarioClient(context);
const sutClient = createMatrixQaScenarioClient({
accessToken: context.sutAccessToken,
baseUrl: context.baseUrl,
});
await driverClient.kickUserFromRoom({
reason: "matrix qa membership loss",
roomId,
userId: context.sutUserId,
});
const leaveEvent = await waitForMembershipEvent({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
membership: "leave",
observedEvents: context.observedEvents,
roomId,
stateKey: context.sutUserId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const noReplyToken = `MATRIX_QA_MEMBERSHIP_LOSS_${randomUUID().slice(0, 8).toUpperCase()}`;
await runNoReplyExpectedScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
baseUrl: context.baseUrl,
body: buildMentionPrompt(context.sutUserId, noReplyToken),
mentionUserIds: [context.sutUserId],
observedEvents: context.observedEvents,
roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
token: noReplyToken,
});
await driverClient.inviteUserToRoom({
roomId,
userId: context.sutUserId,
});
await waitForMembershipEvent({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
membership: "invite",
observedEvents: context.observedEvents,
roomId,
stateKey: context.sutUserId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
await sutClient.joinRoom(roomId);
const joinEvent = await waitForMembershipEvent({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
membership: "join",
observedEvents: context.observedEvents,
roomId,
stateKey: context.sutUserId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const recovered = await runTopologyScopedTopLevelScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
context,
roomKey: MATRIX_QA_MEMBERSHIP_ROOM_KEY,
tokenPrefix: "MATRIX_QA_MEMBERSHIP_RETURN",
});
return {
artifacts: {
...recovered.artifacts,
membershipJoinEventId: joinEvent.eventId,
membershipLeaveEventId: leaveEvent.eventId,
recoveredDriverEventId: recovered.artifacts?.driverEventId,
recoveredReply: recovered.artifacts?.reply,
},
details: [
`room key: ${MATRIX_QA_MEMBERSHIP_ROOM_KEY}`,
`room id: ${roomId}`,
`leave event: ${leaveEvent.eventId}`,
`join event: ${joinEvent.eventId}`,
recovered.details,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runReactionThreadedScenario(context: MatrixQaScenarioContext) {
const thread = await runThreadScenario(context, {
createNestedReply: true,
tokenPrefix: "MATRIX_QA_REACTION_THREAD",
});
assertThreadReplyArtifact(thread.reply, {
expectedRootEventId: thread.rootEventId,
label: "threaded reaction reply",
});
const reaction = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId: thread.reply.eventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: reaction.actorId,
syncState: context.syncState,
nextSince: reaction.since,
startSince: reaction.startSince,
});
return {
artifacts: {
driverEventId: thread.driverEventId,
...buildMatrixQaReactionArtifacts({ reaction }),
reply: thread.reply,
rootEventId: thread.rootEventId,
token: thread.token,
},
details: [
...buildMatrixQaThreadDetailLines({
result: thread,
includeNestedTrigger: true,
extraLines: [`thread reply event: ${thread.reply.eventId}`],
replyLabel: "thread reply",
}),
...buildMatrixQaReactionDetailLines({
reactionEmoji: reaction.reactionEmoji,
reactionEventId: reaction.reactionEventId,
reactionTargetEventId: reaction.reactionTargetEventId,
}),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,627 @@
import { randomUUID } from "node:crypto";
import { createMatrixQaClient, type MatrixQaRoomObserver } from "../../substrate/client.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { createMatrixQaRoomObserver } from "../../substrate/sync.js";
import { type MatrixQaProvisionedTopology } from "../../substrate/topology.js";
import { resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import type {
MatrixQaCanaryArtifact,
MatrixQaReplyArtifact,
MatrixQaScenarioExecution,
} from "./scenario-types.js";
export type MatrixQaActorId = "driver" | "observer";
export type MatrixQaSyncState = Partial<Record<MatrixQaActorId, string>>;
export type MatrixQaSyncStreams = Partial<Record<MatrixQaActorId, MatrixQaRoomObserver>>;
export type MatrixQaScenarioContext = {
baseUrl: string;
canary?: MatrixQaCanaryArtifact;
driverAccessToken: string;
driverDeviceId?: string;
driverPassword?: string;
driverUserId: string;
observedEvents: MatrixQaObservedEvent[];
observerAccessToken: string;
observerDeviceId?: string;
observerPassword?: string;
observerUserId: string;
gatewayStateDir?: string;
outputDir?: string;
restartGateway?: () => Promise<void>;
restartGatewayAfterStateMutation?: (
mutateState: (context: { stateDir: string }) => Promise<void>,
) => Promise<void>;
restartGatewayWithQueuedMessage?: (queueMessage: () => Promise<void>) => Promise<void>;
roomId: string;
interruptTransport?: () => Promise<void>;
sutAccessToken: string;
sutAccountId?: string;
sutDeviceId?: string;
sutPassword?: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
topology: MatrixQaProvisionedTopology;
patchGatewayConfig?: (
patch: Record<string, unknown>,
opts?: { restartDelayMs?: number },
) => Promise<void>;
};
export const NO_REPLY_WINDOW_MS = 8_000;
export function buildMentionPrompt(sutUserId: string, token: string) {
return `${sutUserId} reply with only this exact marker: ${token}`;
}
export function buildExactMarkerPrompt(token: string) {
return `reply with only this exact marker: ${token}`;
}
export function buildMatrixQaToken(prefix: string) {
return `${prefix}_${randomUUID().slice(0, 8).toUpperCase()}`;
}
export function buildMatrixQuietStreamingPrompt(sutUserId: string, text: string) {
return `${sutUserId} Quiet streaming QA check: reply exactly \`${text}\`.`;
}
export function buildMatrixBlockStreamingPrompt(
sutUserId: string,
firstText: string,
secondText: string,
) {
return [
sutUserId,
"Block streaming QA check:",
"emit exactly two assistant message blocks in order.",
`First exact marker: \`${firstText}\`.`,
`Second exact marker: \`${secondText}\`.`,
].join(" ");
}
export function isMatrixQaMessageLikeKind(kind: MatrixQaObservedEvent["kind"]) {
return kind === "message" || kind === "notice";
}
export function doesMatrixQaReplyBodyMatchToken(event: MatrixQaObservedEvent, token: string) {
return event.body?.trim() === token;
}
export function isMatrixQaExactMarkerReply(
event: MatrixQaObservedEvent,
params: {
roomId: string;
sutUserId: string;
token: string;
},
) {
return (
event.roomId === params.roomId &&
event.sender === params.sutUserId &&
event.type === "m.room.message" &&
isMatrixQaMessageLikeKind(event.kind) &&
doesMatrixQaReplyBodyMatchToken(event, params.token)
);
}
export function buildMatrixReplyArtifact(
event: MatrixQaObservedEvent,
token?: string,
): MatrixQaReplyArtifact {
const replyBody = event.body?.trim();
return {
bodyPreview: replyBody?.slice(0, 200),
eventId: event.eventId,
mentions: event.mentions,
relatesTo: event.relatesTo,
sender: event.sender,
...(token ? { tokenMatched: doesMatrixQaReplyBodyMatchToken(event, token) } : {}),
};
}
export function buildMatrixNoticeArtifact(event: MatrixQaObservedEvent) {
return {
bodyPreview: event.body?.trim().slice(0, 200),
eventId: event.eventId,
sender: event.sender,
};
}
export function buildMatrixReplyDetails(label: string, artifact: MatrixQaReplyArtifact) {
return [
`${label} event: ${artifact.eventId}`,
`${label} token matched: ${
artifact.tokenMatched === undefined ? "n/a" : artifact.tokenMatched ? "yes" : "no"
}`,
`${label} rel_type: ${artifact.relatesTo?.relType ?? "<none>"}`,
`${label} in_reply_to: ${artifact.relatesTo?.inReplyToId ?? "<none>"}`,
`${label} is_falling_back: ${artifact.relatesTo?.isFallingBack === true ? "true" : "false"}`,
];
}
export function assertTopLevelReplyArtifact(label: string, artifact: MatrixQaReplyArtifact) {
if (!artifact.tokenMatched) {
throw new Error(`${label} did not contain the expected token`);
}
if (artifact.relatesTo !== undefined) {
throw new Error(`${label} unexpectedly included relation metadata`);
}
}
export function assertThreadReplyArtifact(
artifact: MatrixQaReplyArtifact,
params: {
expectedRootEventId: string;
label: string;
},
) {
if (!artifact.tokenMatched) {
throw new Error(`${params.label} did not contain the expected token`);
}
if (artifact.relatesTo?.relType !== "m.thread") {
throw new Error(`${params.label} did not use m.thread`);
}
if (artifact.relatesTo.eventId !== params.expectedRootEventId) {
throw new Error(
`${params.label} targeted ${artifact.relatesTo.eventId ?? "<none>"} instead of ${params.expectedRootEventId}`,
);
}
if (artifact.relatesTo.isFallingBack !== true) {
throw new Error(`${params.label} did not set is_falling_back`);
}
if (!artifact.relatesTo.inReplyToId) {
throw new Error(`${params.label} did not set m.in_reply_to`);
}
}
export function readMatrixQaSyncCursor(syncState: MatrixQaSyncState, actorId: MatrixQaActorId) {
return syncState[actorId];
}
export function writeMatrixQaSyncCursor(
syncState: MatrixQaSyncState,
actorId: MatrixQaActorId,
since?: string,
) {
if (since) {
syncState[actorId] = since;
}
}
function getOrCreateMatrixQaActorSyncStream(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const existingStream = params.syncStreams?.[params.actorId];
if (existingStream) {
return existingStream;
}
const stream = createMatrixQaRoomObserver({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
since: readMatrixQaSyncCursor(params.syncState, params.actorId),
});
if (params.syncStreams) {
params.syncStreams[params.actorId] = stream;
}
return stream;
}
export function createMatrixQaScenarioClient(params: {
accessToken: string;
actorId?: MatrixQaActorId;
baseUrl: string;
observedEvents?: MatrixQaObservedEvent[];
syncState?: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const syncObserver =
params.actorId && params.observedEvents && params.syncState && params.syncStreams
? getOrCreateMatrixQaActorSyncStream({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
})
: undefined;
return createMatrixQaClient({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
...(syncObserver ? { syncObserver } : {}),
});
}
export function createMatrixQaDriverScenarioClient(context: MatrixQaScenarioContext) {
return createMatrixQaScenarioClient({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
export async function primeMatrixQaActorCursor(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const client = createMatrixQaScenarioClient({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const existingSince = readMatrixQaSyncCursor(params.syncState, params.actorId);
if (existingSince) {
return { client, startSince: existingSince };
}
const startSince = await client.primeRoom();
if (!startSince) {
throw new Error(`Matrix ${params.actorId} /sync prime did not return a next_batch cursor`);
}
return { client, startSince };
}
export async function primeMatrixQaDriverScenarioClient(context: MatrixQaScenarioContext) {
return await primeMatrixQaActorCursor({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
export function advanceMatrixQaActorCursor(params: {
actorId: MatrixQaActorId;
syncState: MatrixQaSyncState;
nextSince?: string;
startSince: string;
}) {
writeMatrixQaSyncCursor(params.syncState, params.actorId, params.nextSince ?? params.startSince);
}
type MatrixQaScenarioClient = ReturnType<typeof createMatrixQaScenarioClient>;
export async function assertNoSutReplyWindow(params: {
actorId: MatrixQaActorId;
client: MatrixQaScenarioClient;
context: MatrixQaScenarioContext;
roomId: string;
since?: string;
startSince: string;
unexpectedLines?: string[];
unexpectedMessage: string;
}) {
const noReplyWindowMs = Math.min(NO_REPLY_WINDOW_MS, params.context.timeoutMs);
const result = await params.client.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message",
roomId: params.roomId,
since: params.since,
timeoutMs: noReplyWindowMs,
});
if (result.matched) {
throw new Error(
[
params.unexpectedMessage,
...(params.unexpectedLines ?? []),
...buildMatrixReplyDetails("unexpected reply", buildMatrixReplyArtifact(result.event)),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.context.syncState,
nextSince: result.since,
startSince: params.startSince,
});
return {
noReplyWindowMs,
since: result.since,
};
}
export async function runConfigurableTopLevelScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
replyPredicate?: (
event: MatrixQaObservedEvent,
params: { driverEventId: string; token: string },
) => boolean;
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
withMention?: boolean;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const token = buildMatrixQaToken(params.tokenPrefix);
const body =
params.withMention === false
? buildExactMarkerPrompt(token)
: buildMentionPrompt(params.sutUserId, token);
const driverEventId = await client.sendTextMessage({
body,
...(params.withMention === false ? {} : { mentionUserIds: [params.sutUserId] }),
roomId: params.roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: params.roomId,
sutUserId: params.sutUserId,
token,
}) &&
(params.replyPredicate?.(event, { driverEventId, token }) ?? event.relatesTo === undefined),
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: matched.since,
startSince,
});
return {
body,
driverEventId,
reply: buildMatrixReplyArtifact(matched.event, token),
token,
};
}
export async function runTopLevelMentionScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
withMention?: boolean;
}) {
return await runConfigurableTopLevelScenario(params);
}
export async function runDriverTopLevelMentionScenario(params: {
baseUrl: string;
driverAccessToken: string;
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
}) {
return await runTopLevelMentionScenario({
accessToken: params.driverAccessToken,
actorId: "driver",
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
roomId: params.roomId,
syncState: params.syncState,
syncStreams: params.syncStreams,
sutUserId: params.sutUserId,
timeoutMs: params.timeoutMs,
tokenPrefix: params.tokenPrefix,
});
}
export async function runAssertedDriverTopLevelScenario(params: {
context: MatrixQaScenarioContext;
label: string;
roomId?: string;
tokenPrefix: string;
}) {
const result = await runDriverTopLevelMentionScenario({
baseUrl: params.context.baseUrl,
driverAccessToken: params.context.driverAccessToken,
observedEvents: params.context.observedEvents,
roomId: params.roomId ?? params.context.roomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: params.tokenPrefix,
});
assertTopLevelReplyArtifact(params.label, result.reply);
return result;
}
export async function waitForMembershipEvent(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
membership: "invite" | "join" | "leave";
observedEvents: MatrixQaObservedEvent[];
roomId: string;
stateKey: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
timeoutMs: number;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.type === "m.room.member" &&
event.stateKey === params.stateKey &&
event.membership === params.membership,
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: matched.since,
startSince,
});
return matched.event;
}
export async function runTopologyScopedTopLevelScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
actorUserId: string;
context: MatrixQaScenarioContext;
roomKey: string;
tokenPrefix: string;
withMention?: boolean;
}) {
const roomId = resolveMatrixQaScenarioRoomId(params.context, params.roomKey);
const result = await runTopLevelMentionScenario({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
roomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: params.tokenPrefix,
withMention: params.withMention,
});
assertTopLevelReplyArtifact(`reply in ${params.roomKey}`, result.reply);
return {
artifacts: {
actorUserId: params.actorUserId,
driverEventId: result.driverEventId,
reply: result.reply,
roomKey: params.roomKey,
token: result.token,
triggerBody: result.body,
},
details: [
`room key: ${params.roomKey}`,
`room id: ${roomId}`,
`driver event: ${result.driverEventId}`,
`trigger sender: ${params.actorUserId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runNoReplyExpectedScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
actorUserId: string;
baseUrl: string;
body: string;
mentionUserIds?: string[];
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
replyPredicate?: (
event: MatrixQaObservedEvent,
match: { driverEventId: string; token: string },
) => boolean;
timeoutMs: number;
token: string;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const driverEventId = await client.sendTextMessage({
body: params.body,
...(params.mentionUserIds ? { mentionUserIds: params.mentionUserIds } : {}),
roomId: params.roomId,
});
const result = await client.waitForOptionalRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.sutUserId &&
event.type === "m.room.message" &&
(params.replyPredicate?.(event, { driverEventId, token: params.token }) ?? true),
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
if (result.matched) {
const unexpectedReply = buildMatrixReplyArtifact(result.event, params.token);
throw new Error(
[
`unexpected SUT reply from ${params.sutUserId}`,
`trigger sender: ${params.actorUserId}`,
...buildMatrixReplyDetails("unexpected reply", unexpectedReply),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: result.since,
startSince,
});
return {
artifacts: {
actorUserId: params.actorUserId,
driverEventId,
expectedNoReplyWindowMs: params.timeoutMs,
token: params.token,
triggerBody: params.body,
},
details: [
`trigger event: ${driverEventId}`,
`trigger sender: ${params.actorUserId}`,
`waited ${params.timeoutMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View file

@ -0,0 +1,216 @@
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
const MATRIX_SYNC_STORE_FILENAME = "bot-storage.json";
const MATRIX_INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json";
const MATRIX_STATE_POLL_INTERVAL_MS = 100;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function readJsonFile(pathname: string): Promise<unknown> {
return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown;
}
async function writeJsonFile(pathname: string, value: unknown) {
await fs.writeFile(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function findFilesByName(params: {
filename: string;
rootDir: string;
maxDepth?: number;
}): Promise<string[]> {
const maxDepth = params.maxDepth ?? 8;
const matches: string[] = [];
async function visit(dir: string, depth: number): Promise<void> {
if (depth > maxDepth) {
return;
}
let entries: Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === params.filename) {
matches.push(entryPath);
continue;
}
if (entry.isDirectory()) {
await visit(entryPath, depth + 1);
}
}
}
await visit(params.rootDir, 0);
return matches.toSorted();
}
function readPersistedMatrixSyncCursor(parsed: unknown): string | null {
if (!isRecord(parsed)) {
return null;
}
const savedSync = parsed.savedSync;
if (isRecord(savedSync) && typeof savedSync.nextBatch === "string") {
return savedSync.nextBatch;
}
if (typeof parsed.next_batch === "string") {
return parsed.next_batch;
}
return null;
}
function writePersistedMatrixSyncCursor(parsed: unknown, cursor: string): unknown {
if (!isRecord(parsed)) {
throw new Error("Matrix sync store was not a JSON object");
}
const savedSync = parsed.savedSync;
if (isRecord(savedSync) && typeof savedSync.nextBatch === "string") {
return {
...parsed,
savedSync: {
...savedSync,
nextBatch: cursor,
},
};
}
if (typeof parsed.next_batch === "string") {
return {
...parsed,
next_batch: cursor,
};
}
throw new Error("Matrix sync store did not contain a persisted sync cursor");
}
async function readMatrixSyncStoreCursor(pathname: string): Promise<string | null> {
return readPersistedMatrixSyncCursor(await readJsonFile(pathname));
}
export async function rewriteMatrixSyncStoreCursor(params: { cursor: string; pathname: string }) {
const parsed = await readJsonFile(params.pathname);
await writeJsonFile(params.pathname, writePersistedMatrixSyncCursor(parsed, params.cursor));
}
async function scoreMatrixStateFile(params: {
context: MatrixQaScenarioContext;
pathname: string;
}) {
let score = params.pathname.includes(`${path.sep}matrix${path.sep}`) ? 4 : 0;
try {
const metadata = await readJsonFile(
path.join(path.dirname(params.pathname), "storage-meta.json"),
);
if (isRecord(metadata) && metadata.userId === params.context.sutUserId) {
score += 16;
}
if (isRecord(metadata) && metadata.accountId === params.context.sutAccountId) {
score += 8;
}
} catch {
// Missing metadata is allowed; the Matrix client may not have flushed it yet.
}
return score;
}
async function resolveBestMatrixStateFile(params: {
context: MatrixQaScenarioContext;
filename: string;
stateDir: string;
}) {
const candidates = await findFilesByName({
filename: params.filename,
rootDir: params.stateDir,
});
if (candidates.length === 0) {
return null;
}
const scored = await Promise.all(
candidates.map(async (pathname) => ({
pathname,
score: await scoreMatrixStateFile({
context: params.context,
pathname,
}),
})),
);
scored.sort((a, b) => b.score - a.score || a.pathname.localeCompare(b.pathname));
return scored[0]?.pathname ?? null;
}
export async function waitForMatrixSyncStoreWithCursor(params: {
context: MatrixQaScenarioContext;
stateDir: string;
timeoutMs: number;
}) {
const startedAt = Date.now();
let lastPath: string | null = null;
while (Date.now() - startedAt < params.timeoutMs) {
const pathname = await resolveBestMatrixStateFile({
context: params.context,
filename: MATRIX_SYNC_STORE_FILENAME,
stateDir: params.stateDir,
});
lastPath = pathname;
if (pathname) {
const cursor = await readMatrixSyncStoreCursor(pathname);
if (cursor) {
return { cursor, pathname };
}
}
await sleep(MATRIX_STATE_POLL_INTERVAL_MS);
}
throw new Error(
`timed out waiting for Matrix sync store cursor under ${params.stateDir}; last path ${lastPath ?? "<none>"}`,
);
}
function hasPersistedMatrixDedupeEntry(params: {
parsed: unknown;
roomId: string;
eventId: string;
}) {
if (!isRecord(params.parsed) || !Array.isArray(params.parsed.entries)) {
return false;
}
const expectedKey = `${params.roomId}|${params.eventId}`;
return params.parsed.entries.some((entry) => isRecord(entry) && entry.key === expectedKey);
}
export async function waitForMatrixInboundDedupeEntry(params: {
context: MatrixQaScenarioContext;
eventId: string;
roomId: string;
stateDir: string;
timeoutMs: number;
}) {
const startedAt = Date.now();
while (Date.now() - startedAt < params.timeoutMs) {
const pathname = await resolveBestMatrixStateFile({
context: params.context,
filename: MATRIX_INBOUND_DEDUPE_FILENAME,
stateDir: params.stateDir,
});
if (pathname) {
const parsed = await readJsonFile(pathname);
if (
hasPersistedMatrixDedupeEntry({
parsed,
roomId: params.roomId,
eventId: params.eventId,
})
) {
return pathname;
}
}
await sleep(MATRIX_STATE_POLL_INTERVAL_MS);
}
throw new Error(
`timed out waiting for Matrix inbound dedupe commit for ${params.roomId}|${params.eventId}`,
);
}

View file

@ -0,0 +1,334 @@
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_SECONDARY_ROOM_KEY,
type MatrixQaScenarioDefinition,
} from "./scenario-catalog.js";
import {
runDmPerRoomSessionOverrideScenario,
runDmSharedSessionNoticeScenario,
runDmThreadReplyOverrideScenario,
} from "./scenario-runtime-dm.js";
import {
runMatrixQaE2eeArtifactRedactionScenario,
runMatrixQaE2eeBasicReplyScenario,
runMatrixQaE2eeBootstrapSuccessScenario,
runMatrixQaE2eeDeviceSasVerificationScenario,
runMatrixQaE2eeDmSasVerificationScenario,
runMatrixQaE2eeKeyBootstrapFailureScenario,
runMatrixQaE2eeMediaImageScenario,
runMatrixQaE2eeQrVerificationScenario,
runMatrixQaE2eeRecoveryKeyLifecycleScenario,
runMatrixQaE2eeRestartResumeScenario,
runMatrixQaE2eeStaleDeviceHygieneScenario,
runMatrixQaE2eeThreadFollowUpScenario,
runMatrixQaE2eeVerificationNoticeNoTriggerScenario,
} from "./scenario-runtime-e2ee.js";
import {
runInboundEditIgnoredScenario,
runInboundEditNoDuplicateTriggerScenario,
} from "./scenario-runtime-edit.js";
import {
runAttachmentOnlyIgnoredScenario,
runGeneratedImageDeliveryScenario,
runImageUnderstandingAttachmentScenario,
runMediaTypeCoverageScenario,
runUnsupportedMediaSafeScenario,
} from "./scenario-runtime-media.js";
import {
runReactionNotAReplyScenario,
runReactionNotificationScenario,
runReactionRedactionObservedScenario,
} from "./scenario-runtime-reaction.js";
import {
runHomeserverRestartResumeScenario,
runInitialCatchupThenIncrementalScenario,
runPostRestartRoomContinueScenario,
runRestartReplayDedupeScenario,
runRestartResumeScenario,
runStaleSyncReplayDedupeScenario,
} from "./scenario-runtime-restart.js";
import {
runAllowlistHotReloadScenario,
runBlockStreamingScenario,
runMatrixQaCanary,
runMembershipLossScenario,
runObserverAllowlistOverrideScenario,
runQuietStreamingPreviewScenario,
runReactionThreadedScenario,
runRoomAutoJoinInviteScenario,
runRoomThreadReplyOverrideScenario,
runSubagentThreadSpawnScenario,
runThreadFollowUpScenario,
runThreadIsolationScenario,
runThreadNestedReplyShapeScenario,
runThreadRootPreservationScenario,
runTopLevelReplyShapeScenario,
} from "./scenario-runtime-room.js";
import {
buildExactMarkerPrompt,
buildMatrixQaToken,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
NO_REPLY_WINDOW_MS,
readMatrixQaSyncCursor,
runNoReplyExpectedScenario,
runTopologyScopedTopLevelScenario,
writeMatrixQaSyncCursor,
type MatrixQaScenarioContext,
type MatrixQaSyncState,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export {
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
readMatrixQaSyncCursor,
runMatrixQaCanary,
writeMatrixQaSyncCursor,
};
export type { MatrixQaScenarioContext, MatrixQaSyncState };
async function runDriverTopologyScopedScenario(params: {
context: MatrixQaScenarioContext;
roomKey: string;
tokenPrefix: string;
withMention?: boolean;
}) {
return await runTopologyScopedTopLevelScenario({
accessToken: params.context.driverAccessToken,
actorId: "driver",
actorUserId: params.context.driverUserId,
context: params.context,
roomKey: params.roomKey,
tokenPrefix: params.tokenPrefix,
...(params.withMention === undefined ? {} : { withMention: params.withMention }),
});
}
async function runNoReplyScenario(params: {
accessToken: string;
actorId: "driver" | "observer";
actorUserId: string;
body: string;
context: MatrixQaScenarioContext;
mentionUserIds?: string[];
timeoutMs?: number;
token: string;
}) {
const timeoutMs = params.timeoutMs ?? params.context.timeoutMs;
return await runNoReplyExpectedScenario({
accessToken: params.accessToken,
actorId: params.actorId,
actorUserId: params.actorUserId,
baseUrl: params.context.baseUrl,
body: params.body,
...(params.mentionUserIds ? { mentionUserIds: params.mentionUserIds } : {}),
observedEvents: params.context.observedEvents,
roomId: params.context.roomId,
syncState: params.context.syncState,
sutUserId: params.context.sutUserId,
timeoutMs,
token: params.token,
});
}
async function runMultiActorOrderingScenario(context: MatrixQaScenarioContext) {
const blockedToken = buildMatrixQaToken("MATRIX_QA_MULTI_BLOCKED");
const blocked = await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: buildMentionPrompt(context.sutUserId, blockedToken),
mentionUserIds: [context.sutUserId],
context,
timeoutMs: Math.min(NO_REPLY_WINDOW_MS, context.timeoutMs),
token: blockedToken,
});
const accepted = await runDriverTopologyScopedScenario({
context,
roomKey: context.topology.defaultRoomKey,
tokenPrefix: "MATRIX_QA_MULTI_DRIVER",
});
return {
artifacts: {
accepted: accepted.artifacts ?? {},
blocked: blocked.artifacts ?? {},
},
details: [blocked.details, accepted.details].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runMatrixQaScenario(
scenario: MatrixQaScenarioDefinition,
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {
switch (scenario.id) {
case "matrix-thread-follow-up":
return await runThreadFollowUpScenario(context);
case "matrix-thread-root-preservation":
return await runThreadRootPreservationScenario(context);
case "matrix-thread-nested-reply-shape":
return await runThreadNestedReplyShapeScenario(context);
case "matrix-thread-isolation":
return await runThreadIsolationScenario(context);
case "matrix-subagent-thread-spawn":
return await runSubagentThreadSpawnScenario(context);
case "matrix-top-level-reply-shape":
return await runTopLevelReplyShapeScenario(context);
case "matrix-room-thread-reply-override":
return await runRoomThreadReplyOverrideScenario(context);
case "matrix-room-quiet-streaming-preview":
return await runQuietStreamingPreviewScenario(context);
case "matrix-room-block-streaming":
return await runBlockStreamingScenario(context);
case "matrix-room-image-understanding-attachment":
return await runImageUnderstandingAttachmentScenario(context);
case "matrix-room-generated-image-delivery":
return await runGeneratedImageDeliveryScenario(context);
case "matrix-media-type-coverage":
return await runMediaTypeCoverageScenario(context);
case "matrix-attachment-only-ignored":
return await runAttachmentOnlyIgnoredScenario(context);
case "matrix-unsupported-media-safe":
return await runUnsupportedMediaSafeScenario(context);
case "matrix-dm-reply-shape":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_DRIVER_DM_ROOM_KEY,
tokenPrefix: "MATRIX_QA_DM",
withMention: false,
});
case "matrix-dm-shared-session-notice":
return await runDmSharedSessionNoticeScenario(context);
case "matrix-dm-thread-reply-override":
return await runDmThreadReplyOverrideScenario(context);
case "matrix-dm-per-room-session-override":
return await runDmPerRoomSessionOverrideScenario(context);
case "matrix-room-autojoin-invite":
return await runRoomAutoJoinInviteScenario(context);
case "matrix-secondary-room-reply":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_SECONDARY_ROOM_KEY,
tokenPrefix: "MATRIX_QA_SECONDARY",
});
case "matrix-secondary-room-open-trigger":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_SECONDARY_ROOM_KEY,
tokenPrefix: "MATRIX_QA_SECONDARY_OPEN",
withMention: false,
});
case "matrix-reaction-notification":
return await runReactionNotificationScenario(context);
case "matrix-reaction-threaded":
return await runReactionThreadedScenario(context);
case "matrix-reaction-not-a-reply":
return await runReactionNotAReplyScenario(context);
case "matrix-reaction-redaction-observed":
return await runReactionRedactionObservedScenario(context);
case "matrix-restart-resume":
return await runRestartResumeScenario(context);
case "matrix-post-restart-room-continue":
return await runPostRestartRoomContinueScenario(context);
case "matrix-initial-catchup-then-incremental":
return await runInitialCatchupThenIncrementalScenario(context);
case "matrix-restart-replay-dedupe":
return await runRestartReplayDedupeScenario(context);
case "matrix-stale-sync-replay-dedupe":
return await runStaleSyncReplayDedupeScenario(context);
case "matrix-room-membership-loss":
return await runMembershipLossScenario(context);
case "matrix-homeserver-restart-resume":
return await runHomeserverRestartResumeScenario(context);
case "matrix-mention-gating": {
const token = buildMatrixQaToken("MATRIX_QA_NOMENTION");
return await runNoReplyScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
body: buildExactMarkerPrompt(token),
context,
token,
});
}
case "matrix-mxid-prefixed-command-block": {
const token = buildMatrixQaToken("MATRIX_QA_MXID_COMMAND");
return await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: `${context.sutUserId} /new`,
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-mention-metadata-spoof-block": {
const token = buildMatrixQaToken("MATRIX_QA_METADATA_SPOOF");
return await runNoReplyScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
body: buildExactMarkerPrompt(token),
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-observer-allowlist-override":
return await runObserverAllowlistOverrideScenario(context);
case "matrix-allowlist-block": {
const token = buildMatrixQaToken("MATRIX_QA_ALLOWLIST");
return await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: buildMentionPrompt(context.sutUserId, token),
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-allowlist-hot-reload":
return await runAllowlistHotReloadScenario(context);
case "matrix-multi-actor-ordering":
return await runMultiActorOrderingScenario(context);
case "matrix-inbound-edit-ignored":
return await runInboundEditIgnoredScenario(context);
case "matrix-inbound-edit-no-duplicate-trigger":
return await runInboundEditNoDuplicateTriggerScenario(context);
case "matrix-e2ee-basic-reply":
return await runMatrixQaE2eeBasicReplyScenario(context);
case "matrix-e2ee-thread-follow-up":
return await runMatrixQaE2eeThreadFollowUpScenario(context);
case "matrix-e2ee-bootstrap-success":
return await runMatrixQaE2eeBootstrapSuccessScenario(context);
case "matrix-e2ee-recovery-key-lifecycle":
return await runMatrixQaE2eeRecoveryKeyLifecycleScenario(context);
case "matrix-e2ee-device-sas-verification":
return await runMatrixQaE2eeDeviceSasVerificationScenario(context);
case "matrix-e2ee-qr-verification":
return await runMatrixQaE2eeQrVerificationScenario(context);
case "matrix-e2ee-stale-device-hygiene":
return await runMatrixQaE2eeStaleDeviceHygieneScenario(context);
case "matrix-e2ee-dm-sas-verification":
return await runMatrixQaE2eeDmSasVerificationScenario(context);
case "matrix-e2ee-restart-resume":
return await runMatrixQaE2eeRestartResumeScenario(context);
case "matrix-e2ee-verification-notice-no-trigger":
return await runMatrixQaE2eeVerificationNoticeNoTriggerScenario(context);
case "matrix-e2ee-artifact-redaction":
return await runMatrixQaE2eeArtifactRedactionScenario(context);
case "matrix-e2ee-media-image":
return await runMatrixQaE2eeMediaImageScenario(context);
case "matrix-e2ee-key-bootstrap-failure":
return await runMatrixQaE2eeKeyBootstrapFailureScenario(context);
default: {
const exhaustiveScenarioId: never = scenario.id;
return exhaustiveScenarioId;
}
}
}

View file

@ -0,0 +1,121 @@
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
export type MatrixQaReplyArtifact = {
bodyPreview?: string;
eventId: string;
mentions?: MatrixQaObservedEvent["mentions"];
relatesTo?: MatrixQaObservedEvent["relatesTo"];
sender?: string;
tokenMatched?: boolean;
};
export type MatrixQaCanaryArtifact = {
driverEventId: string;
reply: MatrixQaReplyArtifact;
token: string;
};
export type MatrixQaScenarioArtifacts = {
accepted?: MatrixQaScenarioArtifacts;
attachments?: Array<{
eventId: string;
filename?: string;
kind?: string;
label: string;
msgtype?: string;
}>;
attachmentCaptionPreview?: string;
attachmentBodyPreview?: string;
attachmentEventId?: string;
attachmentFilename?: string;
attachmentKind?: string;
attachmentMsgtype?: string;
actorUserId?: string;
blocked?: MatrixQaScenarioArtifacts;
catchupDriverEventId?: string;
catchupReply?: MatrixQaReplyArtifact;
catchupToken?: string;
dedupeCommitObserved?: boolean;
duplicateWindowMs?: number;
driverEventId?: string;
editEventId?: string;
editedToken?: string;
expectedNoReplyWindowMs?: number;
firstDriverEventId?: string;
firstReply?: MatrixQaReplyArtifact;
firstToken?: string;
freshDriverEventId?: string;
freshReply?: MatrixQaReplyArtifact;
freshToken?: string;
incrementalDriverEventId?: string;
incrementalReply?: MatrixQaReplyArtifact;
incrementalToken?: string;
originalDriverEventId?: string;
originalReply?: MatrixQaReplyArtifact;
originalToken?: string;
reactionEmoji?: string;
reactionEventId?: string;
reactionTargetEventId?: string;
redactionEventId?: string;
reply?: MatrixQaReplyArtifact;
replies?: Array<{
eventId: string;
label: string;
token: string;
tokenMatched?: boolean;
}>;
recoveredDriverEventId?: string;
recoveredReply?: MatrixQaReplyArtifact;
rootEventId?: string;
roomKey?: string;
roomId?: string;
restartSignal?: string;
secondDriverEventId?: string;
secondReply?: MatrixQaReplyArtifact;
secondToken?: string;
staleSyncCursor?: string;
subagentCompletion?: MatrixQaReplyArtifact;
subagentIntro?: MatrixQaReplyArtifact;
threadDriverEventId?: string;
threadReply?: MatrixQaReplyArtifact;
threadRootEventId?: string;
threadToken?: string;
token?: string;
topLevelDriverEventId?: string;
topLevelReply?: MatrixQaReplyArtifact;
topLevelToken?: string;
triggerBody?: string;
membershipJoinEventId?: string;
membershipLeaveEventId?: string;
noticeBodyPreview?: string;
noticeEventId?: string;
previewBodyPreview?: string;
previewEventId?: string;
blockEventIds?: string[];
bootstrapActor?: "driver" | "observer" | "sut";
bootstrapErrorPreview?: string;
bootstrapSuccess?: boolean;
backupCreatedVersion?: string | null;
backupRestored?: boolean;
backupReset?: boolean;
completedVerificationIds?: string[];
currentDeviceId?: string | null;
deletedDeviceIds?: string[];
faultedEndpoint?: string;
faultHitCount?: number;
faultRuleId?: string;
qrBytes?: number;
recoveryKeyId?: string | null;
recoveryKeyStored?: boolean;
remainingDeviceIds?: string[];
sasEmoji?: string[];
secondaryDeviceId?: string;
transportInterruption?: string;
verificationRoomId?: string;
joinedRoomId?: string;
};
export type MatrixQaScenarioExecution = {
artifacts?: MatrixQaScenarioArtifacts;
details: string;
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_E2EE_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
MATRIX_QA_SCENARIOS,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixQaE2eeScenarioRoomKey,
buildMatrixQaTopologyForScenarios,
findMatrixQaScenarios,
resolveMatrixQaScenarioRoomId,
type MatrixQaScenarioDefinition,
type MatrixQaScenarioId,
} from "./scenario-catalog.js";
import {
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
readMatrixQaSyncCursor,
runMatrixQaCanary,
runMatrixQaScenario,
writeMatrixQaSyncCursor,
type MatrixQaScenarioContext,
type MatrixQaSyncState,
} from "./scenario-runtime.js";
import type {
MatrixQaCanaryArtifact,
MatrixQaReplyArtifact,
MatrixQaScenarioArtifacts,
MatrixQaScenarioExecution,
} from "./scenario-types.js";
export type { MatrixQaScenarioDefinition, MatrixQaScenarioId };
export {
MATRIX_QA_SCENARIOS,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMatrixQaE2eeScenarioRoomKey,
buildMatrixQaTopologyForScenarios,
buildMentionPrompt,
findMatrixQaScenarios,
resolveMatrixQaScenarioRoomId,
runMatrixQaCanary,
runMatrixQaScenario,
};
export type {
MatrixQaCanaryArtifact,
MatrixQaReplyArtifact,
MatrixQaScenarioArtifacts,
MatrixQaScenarioExecution,
};
export type { MatrixQaScenarioContext, MatrixQaSyncState };
export const __testing = {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_E2EE_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixQaE2eeScenarioRoomKey,
buildMatrixQaTopologyForScenarios,
buildMatrixReplyDetails,
buildMatrixReplyArtifact,
buildMentionPrompt,
findMatrixQaScenarios,
readMatrixQaSyncCursor,
resolveMatrixQaScenarioRoomId,
writeMatrixQaSyncCursor,
};

View file

@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
describe("matrix qa runtime api surface", () => {
it("keeps runner discovery lightweight", async () => {
const runtimeApi = await import("../runtime-api.js");
expect(Object.keys(runtimeApi).toSorted()).toEqual(["qaRunnerCliRegistrations"]);
});
});

View file

@ -0,0 +1,18 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
export function appendLiveLaneIssue(issues: string[], label: string, error: unknown) {
issues.push(`${label}: ${formatErrorMessage(error)}`);
}
export function buildLiveLaneArtifactsError(params: {
heading: string;
artifacts: Record<string, string>;
details?: string[];
}) {
return [
params.heading,
...(params.details ?? []),
"Artifacts:",
...Object.entries(params.artifacts).map(([label, filePath]) => `- ${label}: ${filePath}`),
].join("\n");
}

View file

@ -0,0 +1,68 @@
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { startLiveTransportQaOutputTee } from "./live-transport-cli.runtime.js";
const tmpDirs: string[] = [];
describe("live transport CLI runtime", () => {
afterEach(async () => {
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("tees stdout and stderr into an output artifact", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
try {
process.stdout.write("stdout marker\n");
process.stderr.write("stderr marker\n");
await tee.stop();
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(process.stdout.write).toBe(originalStdoutWrite);
expect(process.stderr.write).toBe(originalStderrWrite);
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stdout marker\n");
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stderr marker\n");
});
it("surfaces output artifact stream errors after restoring process writes", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
await rm(path.join(outputDir, "matrix-qa-output.log"), { recursive: true, force: true });
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
const mutedStdoutWrite = (() => true) as typeof process.stdout.write;
const mutedStderrWrite = (() => true) as typeof process.stderr.write;
process.stdout.write = mutedStdoutWrite;
process.stderr.write = mutedStderrWrite;
try {
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
process.stdout.write("stdout marker\n");
await expect(tee.stop()).rejects.toMatchObject({ code: "EISDIR" });
expect(process.stdout.write).toBe(mutedStdoutWrite);
expect(process.stderr.write).toBe(mutedStderrWrite);
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
});
});

View file

@ -0,0 +1,106 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { resolveRepoRelativeOutputDir } from "../cli-paths.js";
import type { QaProviderMode } from "../run-config.js";
import { normalizeQaProviderMode } from "../run-config.js";
import type { LiveTransportQaCommandOptions } from "./live-transport-cli.js";
export function resolveLiveTransportQaRunOptions(
opts: LiveTransportQaCommandOptions,
): LiveTransportQaCommandOptions & {
outputDir: string;
repoRoot: string;
providerMode: QaProviderMode;
} {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `matrix-${Date.now().toString(36)}`);
return {
repoRoot,
outputDir,
providerMode:
opts.providerMode === undefined
? "live-frontier"
: normalizeQaProviderMode(opts.providerMode),
primaryModel: opts.primaryModel,
alternateModel: opts.alternateModel,
fastMode: opts.fastMode,
scenarioIds: opts.scenarioIds,
sutAccountId: opts.sutAccountId,
credentialSource: opts.credentialSource?.trim(),
credentialRole: opts.credentialRole?.trim(),
};
}
export function printLiveTransportQaArtifacts(
laneLabel: string,
artifacts: Record<string, string>,
) {
for (const [label, filePath] of Object.entries(artifacts)) {
process.stdout.write(`${laneLabel} ${label}: ${filePath}\n`);
}
}
type ProcessWriteCallback = (err?: Error | null) => void;
export async function startLiveTransportQaOutputTee(params: {
fileName: string;
outputDir: string;
}) {
await fsp.mkdir(params.outputDir, { recursive: true });
const outputPath = path.join(params.outputDir, params.fileName);
const output = fs.createWriteStream(outputPath, {
encoding: "utf8",
flags: "a",
mode: 0o600,
});
let outputError: Error | null = null;
output.on("error", (error) => {
outputError ??= error;
});
const originalStdoutWrite = Reflect.get(process.stdout, "write");
const originalStderrWrite = Reflect.get(process.stderr, "write");
const boundStdoutWrite = originalStdoutWrite.bind(process.stdout);
const boundStderrWrite = originalStderrWrite.bind(process.stderr);
let stopped = false;
const tee = (originalWrite: typeof process.stdout.write) =>
function writeWithTee(
this: NodeJS.WriteStream,
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ProcessWriteCallback,
callback?: ProcessWriteCallback,
) {
if (!stopped && !outputError) {
output.write(chunk);
}
return Reflect.apply(originalWrite, this, [chunk, encodingOrCallback, callback]) as boolean;
};
process.stdout.write = tee(boundStdoutWrite) as typeof process.stdout.write;
process.stderr.write = tee(boundStderrWrite) as typeof process.stderr.write;
return {
outputPath,
async stop() {
if (stopped) {
return;
}
stopped = true;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
if (outputError) {
throw outputError;
}
await new Promise<void>((resolve, reject) => {
output.once("error", reject);
output.end(resolve);
});
if (outputError) {
throw outputError;
}
},
};
}

View file

@ -0,0 +1,132 @@
import type { Command } from "commander";
import { collectString } from "../cli-options.js";
import type { QaProviderModeInput } from "../run-config.js";
export type LiveTransportQaCommandOptions = {
repoRoot?: string;
outputDir?: string;
providerMode?: QaProviderModeInput;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
scenarioIds?: string[];
sutAccountId?: string;
credentialSource?: string;
credentialRole?: string;
};
type LiveTransportQaCommanderOptions = {
repoRoot?: string;
outputDir?: string;
providerMode?: QaProviderModeInput;
model?: string;
altModel?: string;
scenario?: string[];
fast?: boolean;
sutAccount?: string;
credentialSource?: string;
credentialRole?: string;
};
export type LiveTransportQaCliRegistration = {
commandName: string;
register(qa: Command): void;
};
export type LiveTransportQaCredentialCliOptions = {
sourceDescription?: string;
roleDescription?: string;
};
export function createLazyCliRuntimeLoader<T>(load: () => Promise<T>) {
let promise: Promise<T> | null = null;
return async () => {
promise ??= load();
return await promise;
};
}
export function mapLiveTransportQaCommanderOptions(
opts: LiveTransportQaCommanderOptions,
): LiveTransportQaCommandOptions {
return {
repoRoot: opts.repoRoot,
outputDir: opts.outputDir,
providerMode: opts.providerMode,
primaryModel: opts.model,
alternateModel: opts.altModel,
fastMode: opts.fast,
scenarioIds: opts.scenario,
sutAccountId: opts.sutAccount,
credentialSource: opts.credentialSource,
credentialRole: opts.credentialRole,
};
}
export function registerLiveTransportQaCli(params: {
qa: Command;
commandName: string;
credentialOptions?: LiveTransportQaCredentialCliOptions;
description: string;
outputDirHelp: string;
scenarioHelp: string;
sutAccountHelp: string;
run: (opts: LiveTransportQaCommandOptions) => Promise<void>;
}) {
const command = params.qa
.command(params.commandName)
.description(params.description)
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", params.outputDirHelp)
.option(
"--provider-mode <mode>",
"Provider mode: mock-openai or live-frontier (legacy live-openai still works)",
"live-frontier",
)
.option("--model <ref>", "Primary provider/model ref")
.option("--alt-model <ref>", "Alternate provider/model ref")
.option("--scenario <id>", params.scenarioHelp, collectString, [])
.option("--fast", "Enable provider fast mode where supported", false)
.option("--sut-account <id>", params.sutAccountHelp, "sut");
if (params.credentialOptions) {
command.option(
"--credential-source <source>",
params.credentialOptions.sourceDescription ??
"Credential source for live lanes: env or convex (default: env)",
);
if (params.credentialOptions.roleDescription) {
command.option("--credential-role <role>", params.credentialOptions.roleDescription);
}
}
command.action(async (opts: LiveTransportQaCommanderOptions) => {
await params.run(mapLiveTransportQaCommanderOptions(opts));
});
}
export function createLiveTransportQaCliRegistration(params: {
commandName: string;
credentialOptions?: LiveTransportQaCredentialCliOptions;
description: string;
outputDirHelp: string;
scenarioHelp: string;
sutAccountHelp: string;
run: (opts: LiveTransportQaCommandOptions) => Promise<void>;
}): LiveTransportQaCliRegistration {
return {
commandName: params.commandName,
register(qa: Command) {
registerLiveTransportQaCli({
qa,
commandName: params.commandName,
credentialOptions: params.credentialOptions,
description: params.description,
outputDirHelp: params.outputDirHelp,
scenarioHelp: params.scenarioHelp,
sutAccountHelp: params.sutAccountHelp,
run: params.run,
});
},
};
}

View file

@ -0,0 +1,149 @@
export type LiveTransportStandardScenarioId =
| "canary"
| "mention-gating"
| "allowlist-block"
| "top-level-reply-shape"
| "restart-resume"
| "thread-follow-up"
| "thread-isolation"
| "reaction-observation"
| "help-command";
export type LiveTransportScenarioDefinition<TId extends string = string> = {
id: TId;
standardId?: LiveTransportStandardScenarioId;
timeoutMs: number;
title: string;
};
export type LiveTransportStandardScenarioDefinition = {
description: string;
id: LiveTransportStandardScenarioId;
title: string;
};
export const LIVE_TRANSPORT_STANDARD_SCENARIOS: readonly LiveTransportStandardScenarioDefinition[] =
[
{
id: "canary",
title: "Transport canary",
description: "The lane can trigger one known-good reply on the real transport.",
},
{
id: "mention-gating",
title: "Mention gating",
description: "Messages without the required mention do not trigger a reply.",
},
{
id: "allowlist-block",
title: "Sender allowlist block",
description: "Non-allowlisted senders do not trigger a reply.",
},
{
id: "top-level-reply-shape",
title: "Top-level reply shape",
description: "Top-level replies stay top-level when the lane is configured that way.",
},
{
id: "restart-resume",
title: "Restart resume",
description: "The lane still responds after a gateway restart.",
},
{
id: "thread-follow-up",
title: "Thread follow-up",
description: "Threaded prompts receive threaded replies with the expected relation metadata.",
},
{
id: "thread-isolation",
title: "Thread isolation",
description: "Fresh top-level prompts stay out of prior threads.",
},
{
id: "reaction-observation",
title: "Reaction observation",
description: "Reaction events are observed and normalized correctly.",
},
{
id: "help-command",
title: "Help command",
description: "The transport-specific help command path replies successfully.",
},
] as const;
export const LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS: readonly LiveTransportStandardScenarioId[] =
[
"canary",
"mention-gating",
"allowlist-block",
"top-level-reply-shape",
"restart-resume",
] as const;
const LIVE_TRANSPORT_STANDARD_SCENARIO_ID_SET = new Set(
LIVE_TRANSPORT_STANDARD_SCENARIOS.map((scenario) => scenario.id),
);
function assertKnownStandardScenarioIds(ids: readonly LiveTransportStandardScenarioId[]) {
for (const id of ids) {
if (!LIVE_TRANSPORT_STANDARD_SCENARIO_ID_SET.has(id)) {
throw new Error(`unknown live transport standard scenario id: ${id}`);
}
}
}
export function selectLiveTransportScenarios<TDefinition extends { id: string }>(params: {
ids?: string[];
laneLabel: string;
scenarios: readonly TDefinition[];
}) {
if (!params.ids || params.ids.length === 0) {
return [...params.scenarios];
}
const requested = new Set(params.ids);
const selected = params.scenarios.filter((scenario) => params.ids?.includes(scenario.id));
const missingIds = [...requested].filter(
(id) => !selected.some((scenario) => scenario.id === id),
);
if (missingIds.length > 0) {
throw new Error(`unknown ${params.laneLabel} QA scenario id(s): ${missingIds.join(", ")}`);
}
return selected;
}
export function collectLiveTransportStandardScenarioCoverage<TId extends string>(params: {
alwaysOnStandardScenarioIds?: readonly LiveTransportStandardScenarioId[];
scenarios: readonly LiveTransportScenarioDefinition<TId>[];
}) {
const coverage: LiveTransportStandardScenarioId[] = [];
const seen = new Set<LiveTransportStandardScenarioId>();
const append = (id: LiveTransportStandardScenarioId | undefined) => {
if (!id || seen.has(id)) {
return;
}
seen.add(id);
coverage.push(id);
};
assertKnownStandardScenarioIds(params.alwaysOnStandardScenarioIds ?? []);
for (const id of params.alwaysOnStandardScenarioIds ?? []) {
append(id);
}
for (const scenario of params.scenarios) {
if (scenario.standardId) {
assertKnownStandardScenarioIds([scenario.standardId]);
}
append(scenario.standardId);
}
return coverage;
}
export function findMissingLiveTransportStandardScenarios(params: {
coveredStandardScenarioIds: readonly LiveTransportStandardScenarioId[];
expectedStandardScenarioIds: readonly LiveTransportStandardScenarioId[];
}) {
assertKnownStandardScenarioIds(params.coveredStandardScenarioIds);
assertKnownStandardScenarioIds(params.expectedStandardScenarioIds);
const covered = new Set(params.coveredStandardScenarioIds);
return params.expectedStandardScenarioIds.filter((id) => !covered.has(id));
}

View file

@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { buildMatrixQaObservedEventsArtifact } from "./artifacts.js";
describe("matrix observed event artifacts", () => {
it("redacts Matrix observed event content by default in artifacts", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: "secret",
formattedBody: "<p>secret</p>",
msgtype: "m.image",
originServerTs: 1_700_000_000_000,
attachment: {
kind: "image",
caption: "secret",
filename: "qa-lighthouse.png",
},
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
},
],
}),
).toEqual([
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
msgtype: "m.image",
originServerTs: 1_700_000_000_000,
attachment: {
kind: "image",
filename: "qa-lighthouse.png",
},
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
},
]);
});
it("keeps reaction metadata in redacted Matrix observed-event artifacts", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
reaction: {
eventId: "$reply",
key: "👍",
},
relatesTo: {
relType: "m.annotation",
eventId: "$reply",
},
},
],
}),
).toEqual([
{
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
originServerTs: undefined,
msgtype: undefined,
membership: undefined,
relatesTo: {
relType: "m.annotation",
eventId: "$reply",
},
mentions: undefined,
reaction: {
eventId: "$reply",
key: "👍",
},
},
]);
});
it("keeps redaction metadata while still stripping Matrix event content", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "redaction",
roomId: "!room:matrix-qa.test",
eventId: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
originServerTs: 1_700_000_000_123,
},
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$message",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: "private body",
formattedBody: "<p>private body</p>",
msgtype: "m.text",
},
],
}),
).toEqual([
{
kind: "redaction",
roomId: "!room:matrix-qa.test",
eventId: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
originServerTs: 1_700_000_000_123,
msgtype: undefined,
membership: undefined,
relatesTo: undefined,
mentions: undefined,
reaction: undefined,
},
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$message",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
originServerTs: undefined,
msgtype: "m.text",
membership: undefined,
relatesTo: undefined,
mentions: undefined,
reaction: undefined,
},
]);
});
});

View file

@ -0,0 +1,31 @@
import type { MatrixQaObservedEvent } from "./events.js";
export function buildMatrixQaObservedEventsArtifact(params: {
includeContent: boolean;
observedEvents: MatrixQaObservedEvent[];
}) {
return params.observedEvents.map((event) =>
params.includeContent
? event
: {
kind: event.kind,
roomId: event.roomId,
eventId: event.eventId,
sender: event.sender,
stateKey: event.stateKey,
type: event.type,
originServerTs: event.originServerTs,
msgtype: event.msgtype,
membership: event.membership,
relatesTo: event.relatesTo,
mentions: event.mentions,
reaction: event.reaction,
attachment: event.attachment
? {
kind: event.attachment.kind,
...(event.attachment.filename ? { filename: event.attachment.filename } : {}),
}
: undefined,
},
);
}

View file

@ -0,0 +1,603 @@
import { describe, expect, it } from "vitest";
import { __testing, createMatrixQaClient, provisionMatrixQaRoom } from "./client.js";
import { buildDefaultMatrixQaTopologySpec } from "./topology.js";
function resolveRequestUrl(input: RequestInfo | URL) {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
function parseJsonRequestBody(init?: RequestInit) {
if (typeof init?.body !== "string") {
return {};
}
return JSON.parse(init.body) as Record<string, unknown>;
}
describe("matrix driver client", () => {
it("builds Matrix HTML mentions for QA driver messages", () => {
expect(
__testing.buildMatrixQaMessageContent({
body: "@sut:matrix-qa.test reply with exactly: TOKEN",
mentionUserIds: ["@sut:matrix-qa.test"],
}),
).toEqual({
body: "@sut:matrix-qa.test reply with exactly: TOKEN",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body:
'<a href="https://matrix.to/#/%40sut%3Amatrix-qa.test">@sut:matrix-qa.test</a> reply with exactly: TOKEN',
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
});
});
it("omits Matrix HTML markup when the body has no visible mention token", () => {
expect(
__testing.buildMatrixQaMessageContent({
body: "reply with exactly: TOKEN",
mentionUserIds: ["@sut:matrix-qa.test"],
}),
).toEqual({
body: "reply with exactly: TOKEN",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
});
});
it("builds trimmed Matrix reaction relations for QA driver events", () => {
expect(__testing.buildMatrixReactionRelation(" $msg-1 ", " 👍 ")).toEqual({
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
});
});
it("builds Matrix replacement messages with replacement-local mention metadata", () => {
expect(
__testing.buildMatrixQaReplacementMessageContent({
body: "@sut:matrix-qa.test updated prompt",
mentionUserIds: ["@sut:matrix-qa.test"],
targetEventId: " $msg-1 ",
}),
).toEqual({
body: "* @sut:matrix-qa.test updated prompt",
msgtype: "m.text",
"m.new_content": {
body: "@sut:matrix-qa.test updated prompt",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body:
'<a href="https://matrix.to/#/%40sut%3Amatrix-qa.test">@sut:matrix-qa.test</a> updated prompt',
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$msg-1",
},
});
});
it("advances Matrix registration through token then dummy auth stages", () => {
const firstStage = __testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
},
});
expect(firstStage).toEqual({
session: "uiaa-session",
type: "m.login.registration_token",
token: "reg-token",
});
expect(
__testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
completed: ["m.login.registration_token"],
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
},
}),
).toEqual({
session: "uiaa-session",
type: "m.login.dummy",
});
});
it("rejects Matrix UIAA flows that require unsupported stages", () => {
expect(() =>
__testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
flows: [{ stages: ["m.login.registration_token", "m.login.recaptcha", "m.login.dummy"] }],
},
}),
).toThrow("Matrix registration requires unsupported auth stages:");
});
it("logs in with Matrix password auth to create a secondary QA device", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
return new Response(
JSON.stringify({
access_token: "secondary-token",
device_id: "SECONDARYDEVICE",
user_id: "@qa-driver:matrix-qa.test",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const client = createMatrixQaClient({
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.loginWithPassword({
deviceName: "OpenClaw Matrix QA Stale Device",
password: "driver-password",
userId: "@qa-driver:matrix-qa.test",
}),
).resolves.toMatchObject({
accessToken: "secondary-token",
deviceId: "SECONDARYDEVICE",
password: "driver-password",
userId: "@qa-driver:matrix-qa.test",
});
expect(requests).toEqual([
{
url: "http://127.0.0.1:28008/_matrix/client/v3/login",
body: {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: "@qa-driver:matrix-qa.test",
},
initial_device_display_name: "OpenClaw Matrix QA Stale Device",
password: "driver-password",
},
},
]);
});
it("issues Matrix room membership control requests for QA topology changes", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await client.inviteUserToRoom({
roomId: "!room:matrix-qa.test",
userId: "@observer:matrix-qa.test",
});
await client.kickUserFromRoom({
reason: "topology reset",
roomId: "!room:matrix-qa.test",
userId: "@observer:matrix-qa.test",
});
await client.leaveRoom("!room:matrix-qa.test");
expect(requests).toEqual([
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/invite",
body: {
user_id: "@observer:matrix-qa.test",
},
},
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/kick",
body: {
reason: "topology reset",
user_id: "@observer:matrix-qa.test",
},
},
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/leave",
body: {},
},
]);
});
it("sends Matrix reactions through the protocol send endpoint", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
expect(resolveRequestUrl(input)).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.reaction/",
);
expect(parseJsonRequestBody(init)).toEqual({
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
});
return new Response(JSON.stringify({ event_id: "$reaction-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendReaction({
emoji: "👍",
messageId: "$msg-1",
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$reaction-1");
});
it("sends Matrix replacements and redactions through protocol endpoints", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
const eventId = requests.length === 1 ? "$replacement-1" : "$redaction-1";
return new Response(JSON.stringify({ event_id: eventId }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendReplacementMessage({
body: "@sut:matrix-qa.test updated prompt",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
targetEventId: "$msg-1",
}),
).resolves.toBe("$replacement-1");
await expect(
client.redactEvent({
eventId: "$reaction-1",
reason: "qa cleanup",
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$redaction-1");
expect(requests[0]?.url).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
);
expect(requests[0]?.body).toMatchObject({
"m.relates_to": {
rel_type: "m.replace",
event_id: "$msg-1",
},
});
expect(requests[1]).toEqual({
url: expect.stringContaining(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/redact/%24reaction-1/",
),
body: {
reason: "qa cleanup",
},
});
});
it("uploads Matrix media before sending the room event", async () => {
const requests: Array<{
body: RequestInit["body"];
headers: HeadersInit | undefined;
url: string;
}> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: init?.body,
headers: init?.headers,
url: resolveRequestUrl(input),
});
if (requests.length === 1) {
return new Response(
JSON.stringify({ content_uri: "mxc://matrix-qa.test/red-top-blue-bottom" }),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
return new Response(JSON.stringify({ event_id: "$media-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendMediaMessage({
body: "@sut:matrix-qa.test Image understanding check",
buffer: Buffer.from("png-bytes"),
contentType: "image/png",
fileName: "red-top-blue-bottom.png",
kind: "image",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$media-1");
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"http://127.0.0.1:28008/_matrix/media/v3/upload?filename=red-top-blue-bottom.png",
);
expect(requests[0]?.body).toBeInstanceOf(Uint8Array);
expect(Array.from(requests[0]?.body as Uint8Array)).toEqual(
Array.from(Buffer.from("png-bytes")),
);
expect(requests[1]?.url).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
);
expect(
typeof requests[1]?.body === "string" ? JSON.parse(requests[1].body) : requests[1]?.body,
).toMatchObject({
body: "@sut:matrix-qa.test Image understanding check",
msgtype: "m.image",
filename: "red-top-blue-bottom.png",
url: "mxc://matrix-qa.test/red-top-blue-bottom",
info: {
mimetype: "image/png",
size: "png-bytes".length,
},
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
});
});
it("adds Matrix room encryption state when provisioning encrypted QA rooms", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const fetchImpl: typeof fetch = async (input, init) => {
createRoomBodies.push(parseJsonRequestBody(init));
expect(resolveRequestUrl(input)).toBe("http://127.0.0.1:28008/_matrix/client/v3/createRoom");
return new Response(JSON.stringify({ room_id: "!encrypted:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.createPrivateRoom({
encrypted: true,
inviteUserIds: ["@sut:matrix-qa.test"],
name: "Encrypted QA Room",
}),
).resolves.toBe("!encrypted:matrix-qa.test");
expect(createRoomBodies[0]?.initial_state).toContainEqual({
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
});
});
it("provisions a three-member room so Matrix QA runs in a group context", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const fetchImpl: typeof fetch = async (input, init) => {
const url = resolveRequestUrl(input);
const body = parseJsonRequestBody(init);
if (url.endsWith("/_matrix/client/v3/register")) {
const username = typeof body.username === "string" ? body.username : "";
const auth = typeof body.auth === "object" && body.auth ? body.auth : undefined;
if (!auth) {
return new Response(
JSON.stringify({
session: `session-${username}`,
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
if ((auth as { type?: string }).type === "m.login.registration_token") {
return new Response(
JSON.stringify({
session: `session-${username}`,
completed: ["m.login.registration_token"],
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
access_token: `token-${username}`,
device_id: `device-${username}`,
user_id: `@${username}:matrix-qa.test`,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/_matrix/client/v3/createRoom")) {
createRoomBodies.push(body);
return new Response(JSON.stringify({ room_id: "!room:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/_matrix/client/v3/join/")) {
return new Response(JSON.stringify({ room_id: "!room:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${url}`);
};
const result = await provisionMatrixQaRoom({
baseUrl: "http://127.0.0.1:28008/",
driverLocalpart: "qa-driver",
observerLocalpart: "qa-observer",
registrationToken: "reg-token",
roomName: "OpenClaw Matrix QA",
sutLocalpart: "qa-sut",
fetchImpl,
topology: buildDefaultMatrixQaTopologySpec({
defaultRoomName: "OpenClaw Matrix QA",
}),
});
expect(result.roomId).toBe("!room:matrix-qa.test");
expect(result.topology).toMatchObject({
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@qa-driver:matrix-qa.test",
"@qa-observer:matrix-qa.test",
"@qa-sut:matrix-qa.test",
],
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
});
expect(result.observer.userId).toBe("@qa-observer:matrix-qa.test");
expect(createRoomBodies).toEqual([
expect.objectContaining({
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
is_direct: false,
preset: "private_chat",
}),
]);
});
it("provisions direct-message topology rooms with Matrix direct-room flags", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const roomIds = ["!group:matrix-qa.test", "!dm:matrix-qa.test"];
let registerCount = 0;
const fetchImpl: typeof fetch = async (input, init) => {
const url = resolveRequestUrl(input);
const body = parseJsonRequestBody(init);
if (url.endsWith("/_matrix/client/v3/register")) {
registerCount += 1;
const role = ["driver", "sut", "observer"][registerCount - 1];
return new Response(
JSON.stringify({
access_token: `token-${role}`,
user_id: `@qa-${role}:matrix-qa.test`,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/_matrix/client/v3/createRoom")) {
createRoomBodies.push(body);
return new Response(JSON.stringify({ room_id: roomIds.shift() }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/_matrix/client/v3/join/")) {
return new Response(JSON.stringify({ room_id: "!joined:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${url}`);
};
const result = await provisionMatrixQaRoom({
baseUrl: "http://127.0.0.1:28008/",
driverLocalpart: "qa-driver",
observerLocalpart: "qa-observer",
registrationToken: "reg-token",
roomName: "unused",
sutLocalpart: "qa-sut",
fetchImpl,
topology: {
defaultRoomKey: "group",
rooms: [
{
key: "group",
kind: "group",
members: ["driver", "observer", "sut"],
name: "Matrix Group",
requireMention: true,
},
{
key: "sut-dm",
kind: "dm",
members: ["driver", "sut"],
name: "Matrix Driver/SUT DM",
},
],
},
});
expect(result.topology.rooms).toMatchObject([
{ key: "group", kind: "group", roomId: "!group:matrix-qa.test", requireMention: true },
{ key: "sut-dm", kind: "dm", roomId: "!dm:matrix-qa.test", requireMention: false },
]);
expect(createRoomBodies).toEqual([
expect.objectContaining({
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
is_direct: false,
name: "Matrix Group",
}),
expect.objectContaining({
invite: ["@qa-sut:matrix-qa.test"],
is_direct: true,
name: "Matrix Driver/SUT DM",
}),
]);
});
});

View file

@ -0,0 +1,914 @@
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { MatrixQaObservedEvent } from "./events.js";
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
import {
createMatrixQaRoomObserver,
primeMatrixQaRoom,
waitForMatrixQaRoomEvent,
waitForOptionalMatrixQaRoomEvent,
type MatrixQaRoomObserver,
type MatrixQaRoomEventWaitResult,
} from "./sync.js";
import {
findMatrixQaProvisionedRoom,
type MatrixQaParticipantRole,
type MatrixQaProvisionedTopology,
type MatrixQaTopologyRoomSpec,
type MatrixQaTopologySpec,
} from "./topology.js";
export type { MatrixQaObservedEvent } from "./events.js";
export type { MatrixQaRoomEventWaitResult, MatrixQaRoomObserver } from "./sync.js";
type MatrixQaAuthStage = "m.login.dummy" | "m.login.registration_token";
type MatrixQaRegisterResponse = {
access_token?: string;
device_id?: string;
user_id?: string;
};
type MatrixQaLoginResponse = MatrixQaRegisterResponse;
type MatrixQaRoomCreateResponse = {
room_id?: string;
};
type MatrixQaSendMessageContent = {
body: string;
format?: "org.matrix.custom.html";
formatted_body?: string;
"m.new_content"?: MatrixQaSendMessageContent;
"m.mentions"?: {
user_ids?: string[];
};
"m.relates_to"?:
| {
rel_type: "m.thread";
event_id: string;
is_falling_back: true;
"m.in_reply_to": {
event_id: string;
};
}
| {
rel_type: "m.replace";
event_id: string;
};
msgtype: "m.text";
};
type MatrixQaMediaMessageType = "m.audio" | "m.file" | "m.image" | "m.video";
type MatrixQaSendMediaMessageContent = Omit<MatrixQaSendMessageContent, "msgtype"> & {
filename?: string;
info?: {
mimetype?: string;
size?: number;
};
msgtype: MatrixQaMediaMessageType;
url: string;
};
type MatrixQaSendReactionContent = {
"m.relates_to": {
event_id: string;
key: string;
rel_type: "m.annotation";
};
};
type MatrixQaRoomInitialState = Array<{
content: Record<string, unknown>;
state_key: string;
type: string;
}>;
type MatrixQaUiaaResponse = {
completed?: string[];
flows?: Array<{ stages?: string[] }>;
session?: string;
};
export type MatrixQaRegisteredAccount = {
accessToken: string;
deviceId?: string;
localpart: string;
password: string;
userId: string;
};
export type MatrixQaProvisionResult = {
driver: MatrixQaRegisteredAccount;
observer: MatrixQaRegisteredAccount;
roomId: string;
sut: MatrixQaRegisteredAccount;
topology: MatrixQaProvisionedTopology;
};
function buildMatrixThreadRelation(threadRootEventId: string, replyToEventId?: string) {
return {
"m.relates_to": {
rel_type: "m.thread" as const,
event_id: threadRootEventId,
is_falling_back: true as const,
"m.in_reply_to": {
event_id: replyToEventId?.trim() || threadRootEventId,
},
},
};
}
function buildMatrixReplacementRelation(targetEventId: string) {
const normalizedTargetEventId = targetEventId.trim();
if (!normalizedTargetEventId) {
throw new Error("Matrix replacement requires a target event id");
}
return {
"m.relates_to": {
rel_type: "m.replace" as const,
event_id: normalizedTargetEventId,
},
};
}
function buildMatrixReactionRelation(
messageId: string,
emoji: string,
): MatrixQaSendReactionContent {
const normalizedMessageId = messageId.trim();
const normalizedEmoji = emoji.trim();
if (!normalizedMessageId) {
throw new Error("Matrix reaction requires a messageId");
}
if (!normalizedEmoji) {
throw new Error("Matrix reaction requires an emoji");
}
return {
"m.relates_to": {
rel_type: "m.annotation",
event_id: normalizedMessageId,
key: normalizedEmoji,
},
};
}
function buildMatrixQaRoomInitialState(encrypted?: boolean): MatrixQaRoomInitialState {
const initialState: MatrixQaRoomInitialState = [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
];
if (encrypted === true) {
initialState.push({
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
});
}
return initialState;
}
function escapeMatrixHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return char;
}
});
}
function buildMatrixMentionLink(userId: string) {
const href = `https://matrix.to/#/${encodeURIComponent(userId)}`;
const label = escapeMatrixHtml(userId);
return `<a href="${href}">${label}</a>`;
}
export function buildMatrixQaMessageContent(params: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
threadRootEventId?: string;
}): MatrixQaSendMessageContent {
const body = params.body;
const uniqueMentionUserIds = [...new Set(params.mentionUserIds?.filter(Boolean) ?? [])];
const formattedParts: string[] = [];
let cursor = 0;
let usedFormattedMention = false;
while (cursor < body.length) {
let matchedUserId: string | null = null;
for (const userId of uniqueMentionUserIds) {
if (body.startsWith(userId, cursor)) {
matchedUserId = userId;
break;
}
}
if (matchedUserId) {
formattedParts.push(buildMatrixMentionLink(matchedUserId));
cursor += matchedUserId.length;
usedFormattedMention = true;
continue;
}
formattedParts.push(escapeMatrixHtml(body[cursor] ?? ""));
cursor += 1;
}
return {
body,
msgtype: "m.text",
...(usedFormattedMention
? {
format: "org.matrix.custom.html" as const,
formatted_body: formattedParts.join(""),
}
: {}),
...(uniqueMentionUserIds.length > 0
? { "m.mentions": { user_ids: uniqueMentionUserIds } }
: {}),
...(params.threadRootEventId
? buildMatrixThreadRelation(params.threadRootEventId, params.replyToEventId)
: {}),
};
}
function buildMatrixQaReplacementMessageContent(params: {
body: string;
mentionUserIds?: string[];
targetEventId: string;
}): MatrixQaSendMessageContent {
const newContent = buildMatrixQaMessageContent({
body: params.body,
mentionUserIds: params.mentionUserIds,
});
return {
body: `* ${params.body}`,
msgtype: "m.text",
"m.new_content": newContent,
...buildMatrixReplacementRelation(params.targetEventId),
};
}
function resolveMatrixQaMediaMsgtype(params: {
contentType?: string;
kind?: "audio" | "file" | "image" | "video";
}): MatrixQaMediaMessageType {
if (params.kind === "audio" || params.contentType?.startsWith("audio/")) {
return "m.audio";
}
if (params.kind === "video" || params.contentType?.startsWith("video/")) {
return "m.video";
}
if (params.kind === "image" || params.contentType?.startsWith("image/")) {
return "m.image";
}
return "m.file";
}
function buildMatrixQaMediaMessageContent(params: {
body?: string;
contentType?: string;
fileName?: string;
kind?: "audio" | "file" | "image" | "video";
mentionUserIds?: string[];
replyToEventId?: string;
size: number;
threadRootEventId?: string;
url: string;
}): MatrixQaSendMediaMessageContent {
const normalizedBody = params.body?.trim() || params.fileName?.trim() || "(file)";
const content = buildMatrixQaMessageContent({
body: normalizedBody,
mentionUserIds: params.mentionUserIds,
replyToEventId: params.replyToEventId,
threadRootEventId: params.threadRootEventId,
});
return {
...content,
filename: params.fileName?.trim() || undefined,
info: {
...(params.contentType ? { mimetype: params.contentType } : {}),
size: params.size,
},
msgtype: resolveMatrixQaMediaMsgtype({
contentType: params.contentType,
kind: params.kind,
}),
url: params.url,
};
}
async function uploadMatrixQaContent(params: {
accessToken?: string;
baseUrl: string;
buffer: Buffer;
contentType?: string;
fetchImpl: MatrixQaFetchLike;
fileName?: string;
}) {
const url = new URL("/_matrix/media/v3/upload", params.baseUrl);
const fileName = params.fileName?.trim();
if (fileName) {
url.searchParams.set("filename", fileName);
}
const uploadBody: Uint8Array<ArrayBuffer> =
params.buffer.buffer instanceof ArrayBuffer
? new Uint8Array(params.buffer.buffer, params.buffer.byteOffset, params.buffer.byteLength)
: Uint8Array.from(params.buffer);
const response = await params.fetchImpl(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": params.contentType ?? "application/octet-stream",
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
},
body: uploadBody,
signal: AbortSignal.timeout(20_000),
});
const body = (await response.json().catch(() => ({}))) as {
content_uri?: string;
error?: string;
};
if (response.status !== 200) {
throw new Error(body.error ?? `Matrix media upload failed with status ${response.status}`);
}
const contentUri = body.content_uri?.trim();
if (!contentUri) {
throw new Error("Matrix media upload did not return content_uri.");
}
return contentUri;
}
export function resolveNextRegistrationAuth(params: {
registrationToken: string;
response: MatrixQaUiaaResponse;
}) {
const session = params.response.session?.trim();
if (!session) {
throw new Error("Matrix registration UIAA response did not include a session id.");
}
const completed = new Set(
(params.response.completed ?? []).filter(
(stage): stage is MatrixQaAuthStage =>
stage === "m.login.dummy" || stage === "m.login.registration_token",
),
);
const supportedStages = new Set<MatrixQaAuthStage>([
"m.login.registration_token",
"m.login.dummy",
]);
for (const flow of params.response.flows ?? []) {
const flowStages = flow.stages ?? [];
if (
flowStages.length === 0 ||
flowStages.some((stage) => !supportedStages.has(stage as MatrixQaAuthStage))
) {
continue;
}
const stages = flowStages as MatrixQaAuthStage[];
const nextStage = stages.find((stage) => !completed.has(stage));
if (!nextStage) {
continue;
}
if (nextStage === "m.login.registration_token") {
return {
session,
type: nextStage,
token: params.registrationToken,
};
}
return {
session,
type: nextStage,
};
}
throw new Error(
`Matrix registration requires unsupported auth stages: ${JSON.stringify(params.response.flows ?? [])}`,
);
}
function buildRegisteredAccount(params: {
localpart: string;
password: string;
response: MatrixQaRegisterResponse;
}) {
const userId = params.response.user_id?.trim();
const accessToken = params.response.access_token?.trim();
if (!userId || !accessToken) {
throw new Error("Matrix registration did not return both user_id and access_token.");
}
return {
accessToken,
deviceId: params.response.device_id?.trim() || undefined,
localpart: params.localpart,
password: params.password,
userId,
} satisfies MatrixQaRegisteredAccount;
}
function resolveMatrixQaLoginUser(params: { localpart?: string; userId?: string }) {
const user = params.userId?.trim() || params.localpart?.trim();
if (!user) {
throw new Error("Matrix password login requires a localpart or userId.");
}
return user;
}
export function createMatrixQaClient(params: {
accessToken?: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
syncObserver?: MatrixQaRoomObserver;
}) {
const fetchImpl = params.fetchImpl ?? fetch;
const syncObserver = params.syncObserver;
const sendEvent = async (opts: { body: unknown; endpoint: string; errorLabel: string }) => {
const result = await requestMatrixJson<{ event_id?: string }>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: opts.body,
endpoint: opts.endpoint,
fetchImpl,
method: "PUT",
});
const eventId = result.body.event_id?.trim();
if (!eventId) {
throw new Error(`Matrix ${opts.errorLabel} did not return event_id.`);
}
return eventId;
};
return {
async createPrivateRoom(opts: {
encrypted?: boolean;
inviteUserIds: string[];
isDirect?: boolean;
name: string;
}) {
const result = await requestMatrixJson<MatrixQaRoomCreateResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
creation_content: { "m.federate": false },
initial_state: buildMatrixQaRoomInitialState(opts.encrypted),
invite: opts.inviteUserIds,
is_direct: opts.isDirect === true,
name: opts.name,
preset: "private_chat",
},
endpoint: "/_matrix/client/v3/createRoom",
fetchImpl,
method: "POST",
});
const roomId = result.body.room_id?.trim();
if (!roomId) {
throw new Error("Matrix createRoom did not return room_id.");
}
return roomId;
},
async primeRoom() {
if (syncObserver) {
return await syncObserver.prime();
}
return await primeMatrixQaRoom({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
});
},
async registerWithToken(opts: {
deviceName: string;
localpart: string;
password: string;
registrationToken: string;
}) {
let auth: Record<string, unknown> | undefined;
const baseBody = {
inhibit_login: false,
initial_device_display_name: opts.deviceName,
password: opts.password,
username: opts.localpart,
};
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await requestMatrixJson<MatrixQaRegisterResponse | MatrixQaUiaaResponse>({
baseUrl: params.baseUrl,
body: {
...baseBody,
...(auth ? { auth } : {}),
},
endpoint: "/_matrix/client/v3/register",
fetchImpl,
method: "POST",
okStatuses: [200, 401],
timeoutMs: 30_000,
});
if (response.status === 200) {
return buildRegisteredAccount({
localpart: opts.localpart,
password: opts.password,
response: response.body as MatrixQaRegisterResponse,
});
}
auth = resolveNextRegistrationAuth({
registrationToken: opts.registrationToken,
response: response.body as MatrixQaUiaaResponse,
});
}
throw new Error(
`Matrix registration for ${opts.localpart} did not complete after 4 attempts.`,
);
},
async loginWithPassword(opts: {
deviceName: string;
localpart?: string;
password: string;
userId?: string;
}) {
const result = await requestMatrixJson<MatrixQaLoginResponse>({
baseUrl: params.baseUrl,
body: {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: resolveMatrixQaLoginUser(opts),
},
initial_device_display_name: opts.deviceName,
password: opts.password,
},
endpoint: "/_matrix/client/v3/login",
fetchImpl,
method: "POST",
timeoutMs: 30_000,
});
return buildRegisteredAccount({
localpart: opts.localpart ?? opts.userId ?? "",
password: opts.password,
response: result.body,
});
},
async sendTextMessage(opts: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaMessageContent(opts),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendMessage",
});
},
async sendReplacementMessage(opts: {
body: string;
mentionUserIds?: string[];
roomId: string;
targetEventId: string;
}) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaReplacementMessageContent(opts),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendReplacementMessage",
});
},
async sendMediaMessage(opts: {
body?: string;
buffer: Buffer;
contentType?: string;
fileName?: string;
kind?: "audio" | "file" | "image" | "video";
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}) {
const contentUri = await uploadMatrixQaContent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
buffer: opts.buffer,
contentType: opts.contentType,
fetchImpl,
fileName: opts.fileName,
});
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaMediaMessageContent({
body: opts.body,
contentType: opts.contentType,
fileName: opts.fileName,
kind: opts.kind,
mentionUserIds: opts.mentionUserIds,
replyToEventId: opts.replyToEventId,
size: opts.buffer.byteLength,
threadRootEventId: opts.threadRootEventId,
url: contentUri,
}),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendMediaMessage",
});
},
async redactEvent(opts: { eventId: string; reason?: string; roomId: string }) {
const txnId = randomUUID();
const reason = opts.reason?.trim();
return await sendEvent({
body: reason ? { reason } : {},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/redact/${encodeURIComponent(opts.eventId)}/${encodeURIComponent(txnId)}`,
errorLabel: "redactEvent",
});
},
async sendReaction(opts: { emoji: string; messageId: string; roomId: string }) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixReactionRelation(opts.messageId, opts.emoji),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`,
errorLabel: "sendReaction",
});
},
async joinRoom(roomId: string) {
const result = await requestMatrixJson<{ room_id?: string }>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {},
endpoint: `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`,
fetchImpl,
method: "POST",
});
return result.body.room_id?.trim() || roomId;
},
async inviteUserToRoom(opts: { roomId: string; userId: string }) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
user_id: opts.userId,
},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite`,
fetchImpl,
method: "POST",
});
},
async kickUserFromRoom(opts: { reason?: string; roomId: string; userId: string }) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
user_id: opts.userId,
...(opts.reason?.trim() ? { reason: opts.reason.trim() } : {}),
},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/kick`,
fetchImpl,
method: "POST",
});
},
async leaveRoom(roomId: string) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`,
fetchImpl,
method: "POST",
});
},
waitForOptionalRoomEvent(opts: {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
}) {
if (syncObserver) {
return syncObserver.waitForOptionalRoomEvent({
predicate: opts.predicate,
roomId: opts.roomId,
timeoutMs: opts.timeoutMs,
});
}
return waitForOptionalMatrixQaRoomEvent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
...opts,
});
},
async waitForRoomEvent(opts: {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
}) {
if (syncObserver) {
return await syncObserver.waitForRoomEvent({
predicate: opts.predicate,
roomId: opts.roomId,
timeoutMs: opts.timeoutMs,
});
}
return await waitForMatrixQaRoomEvent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
...opts,
});
},
};
}
async function joinRoomWithRetry(params: {
accessToken: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
roomId: string;
}) {
const client = createMatrixQaClient({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
let lastError: unknown = null;
for (let attempt = 1; attempt <= 10; attempt += 1) {
try {
await client.joinRoom(params.roomId);
return;
} catch (error) {
lastError = error;
await sleep(300 * attempt);
}
}
throw new Error(`Matrix join retry failed: ${formatErrorMessage(lastError)}`);
}
function resolveProvisionedRoomRequireMention(room: MatrixQaTopologyRoomSpec) {
return room.kind === "group" ? room.requireMention !== false : false;
}
function resolveTopologyMemberAccounts(
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>,
memberRoles: MatrixQaParticipantRole[],
) {
const uniqueRoles = [...new Set(memberRoles)];
if (uniqueRoles.length === 0) {
throw new Error("Matrix QA room provisioning requires at least one member");
}
return uniqueRoles.map((role) => ({
role,
account: accounts[role],
}));
}
async function provisionMatrixQaTopology(params: {
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
spec: MatrixQaTopologySpec;
}): Promise<MatrixQaProvisionedTopology> {
const rooms = [];
for (const room of params.spec.rooms) {
const members = resolveTopologyMemberAccounts(params.accounts, room.members);
const creator = members[0];
const invitees = members.slice(1);
const creatorClient = createMatrixQaClient({
accessToken: creator.account.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
const roomId = await creatorClient.createPrivateRoom({
encrypted: room.encrypted === true,
inviteUserIds: invitees.map((entry) => entry.account.userId),
isDirect: room.kind === "dm",
name: room.name,
});
await Promise.all(
invitees.map((invitee) =>
joinRoomWithRetry({
accessToken: invitee.account.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
roomId,
}),
),
);
rooms.push({
encrypted: room.encrypted === true,
key: room.key,
kind: room.kind,
memberRoles: members.map((entry) => entry.role),
memberUserIds: members.map((entry) => entry.account.userId),
name: room.name,
requireMention: resolveProvisionedRoomRequireMention(room),
roomId,
});
}
const defaultRoom = findMatrixQaProvisionedRoom(
{
defaultRoomId: "",
defaultRoomKey: params.spec.defaultRoomKey,
rooms,
},
params.spec.defaultRoomKey,
);
return {
defaultRoomId: defaultRoom.roomId,
defaultRoomKey: params.spec.defaultRoomKey,
rooms,
};
}
export async function provisionMatrixQaRoom(params: {
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
topology?: MatrixQaTopologySpec;
roomName: string;
driverLocalpart: string;
observerLocalpart: string;
registrationToken: string;
sutLocalpart: string;
}) {
const anonClient = createMatrixQaClient({
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
const [driver, sut, observer] = await Promise.all([
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA Driver",
localpart: params.driverLocalpart,
password: `driver-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA SUT",
localpart: params.sutLocalpart,
password: `sut-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA Observer",
localpart: params.observerLocalpart,
password: `observer-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
]);
const topology = await provisionMatrixQaTopology({
accounts: {
driver,
observer,
sut,
},
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
spec:
params.topology ??
({
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
members: ["driver", "observer", "sut"],
name: params.roomName,
requireMention: true,
},
],
} satisfies MatrixQaTopologySpec),
});
return {
driver,
observer,
roomId: topology.defaultRoomId,
sut,
topology,
} satisfies MatrixQaProvisionResult;
}
export const __testing = {
buildMatrixQaMessageContent,
buildMatrixQaReplacementMessageContent,
buildMatrixReactionRelation,
buildMatrixReplacementRelation,
buildMatrixThreadRelation,
createMatrixQaRoomObserver,
resolveNextRegistrationAuth,
};

View file

@ -0,0 +1,298 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
buildMatrixQaConfig,
buildMatrixQaConfigSnapshot,
summarizeMatrixQaConfigSnapshot,
} from "./config.js";
import type { MatrixQaProvisionedTopology } from "./topology.js";
describe("matrix qa config", () => {
const topology: MatrixQaProvisionedTopology = {
defaultRoomId: "!main:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group" as const,
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Main",
requireMention: true,
roomId: "!main:matrix-qa.test",
},
{
key: "secondary",
kind: "group" as const,
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Secondary",
requireMention: true,
roomId: "!secondary:matrix-qa.test",
},
{
key: "driver-dm",
kind: "dm" as const,
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "DM",
requireMention: false,
roomId: "!dm:matrix-qa.test",
},
],
};
it("builds default Matrix QA config from provisioned topology", () => {
const next = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(next.channels?.matrix?.accounts?.sut).toMatchObject({
dm: {
allowFrom: ["@driver:matrix-qa.test"],
enabled: true,
policy: "allowlist",
},
groupAllowFrom: ["@driver:matrix-qa.test"],
groupPolicy: "allowlist",
groups: {
"!main:matrix-qa.test": { enabled: true, requireMention: true },
"!secondary:matrix-qa.test": { enabled: true, requireMention: true },
},
replyToMode: "off",
threadReplies: "inbound",
});
});
it("applies room-keyed Matrix QA config overrides", () => {
const next = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: [" !dm:matrix-qa.test ", "#ops:matrix-qa.test"],
agentDefaults: {
blockStreamingChunk: {
breakPreference: "newline",
maxChars: 48,
minChars: 1,
},
blockStreamingCoalesce: {
idleMs: 0,
maxChars: 48,
minChars: 1,
},
},
blockStreaming: true,
dm: {
sessionScope: "per-room",
threadReplies: "off",
},
encryption: true,
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
groupsByKey: {
secondary: {
requireMention: false,
tools: {
allow: ["sessions_spawn"],
},
},
},
replyToMode: "all",
streaming: "quiet",
threadBindings: {
enabled: true,
idleHours: 1,
spawnSubagentSessions: true,
},
threadReplies: "always",
toolProfile: "coding",
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(next.agents?.defaults).toMatchObject({
blockStreamingChunk: {
breakPreference: "newline",
maxChars: 48,
minChars: 1,
},
blockStreamingCoalesce: {
idleMs: 0,
maxChars: 48,
minChars: 1,
},
});
expect(next.tools).toMatchObject({
profile: "coding",
});
expect(next.channels?.matrix?.accounts?.sut).toMatchObject({
autoJoin: "allowlist",
autoJoinAllowlist: ["!dm:matrix-qa.test", "#ops:matrix-qa.test"],
blockStreaming: true,
dm: {
sessionScope: "per-room",
threadReplies: "off",
},
encryption: true,
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
groups: {
"!main:matrix-qa.test": { enabled: true, requireMention: true },
"!secondary:matrix-qa.test": {
enabled: true,
requireMention: false,
tools: {
allow: ["sessions_spawn"],
},
},
},
replyToMode: "all",
streaming: "quiet",
threadBindings: {
enabled: true,
idleHours: 1,
spawnSubagentSessions: true,
},
threadReplies: "always",
});
});
it("rewrites the owned Matrix QA account instead of retaining stale override fields", () => {
const overridden = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
streaming: "quiet",
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
const reset = buildMatrixQaConfig(overridden, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(reset.channels?.matrix?.accounts?.sut?.autoJoin).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.autoJoinAllowlist).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.blockStreaming).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.streaming).toBeUndefined();
});
it("builds an effective Matrix QA config snapshot for reporting", () => {
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
dm: {
sessionScope: "per-room",
},
groupPolicy: "open",
streaming: true,
},
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(snapshot).toEqual({
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
dm: {
allowFrom: ["@driver:matrix-qa.test"],
enabled: true,
policy: "allowlist",
sessionScope: "per-room",
threadReplies: "inbound",
},
encryption: false,
groupAllowFrom: ["@driver:matrix-qa.test"],
groupPolicy: "open",
groupsByKey: {
main: {
enabled: true,
requireMention: true,
roomId: "!main:matrix-qa.test",
},
secondary: {
enabled: true,
requireMention: true,
roomId: "!secondary:matrix-qa.test",
},
},
replyToMode: "off",
streaming: "partial",
threadBindings: {},
threadReplies: "inbound",
});
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("autoJoin=allowlist");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=partial");
});
it("resolves role-based Matrix sender allowlist overrides", () => {
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
groupAllowRoles: ["driver", "observer"],
},
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(snapshot.groupAllowFrom).toEqual(["@driver:matrix-qa.test", "@observer:matrix-qa.test"]);
});
it("rejects unknown room-key overrides", () => {
expect(() =>
buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
groupsByKey: {
ghost: {
requireMention: false,
},
},
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
}),
).toThrow('Matrix QA group override references unknown room key "ghost"');
});
});

View file

@ -0,0 +1,452 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MatrixQaProvisionedTopology } from "./topology.js";
export type MatrixQaReplyToMode = "off" | "first" | "all" | "batched";
export type MatrixQaThreadRepliesMode = "off" | "inbound" | "always";
export type MatrixQaDmPolicy = "allowlist" | "disabled" | "open" | "pairing";
export type MatrixQaGroupPolicy = "allowlist" | "disabled" | "open";
export type MatrixQaAutoJoinMode = "allowlist" | "always" | "off";
export type MatrixQaStreamingMode = "off" | "partial" | "quiet";
export type MatrixQaActorRole = "driver" | "observer" | "sut";
export type MatrixQaAgentDefaultsOverrides = {
blockStreamingChunk?: {
breakPreference?: "newline" | "paragraph" | "sentence";
maxChars?: number;
minChars?: number;
};
blockStreamingCoalesce?: {
idleMs?: number;
maxChars?: number;
minChars?: number;
};
};
export type MatrixQaToolConfigOverrides = {
allow?: string[];
deny?: string[];
};
export type MatrixQaGroupConfigOverrides = {
enabled?: boolean;
requireMention?: boolean;
tools?: MatrixQaToolConfigOverrides;
};
export type MatrixQaDmConfigOverrides = {
allowFrom?: string[];
enabled?: boolean;
policy?: MatrixQaDmPolicy;
sessionScope?: "per-room" | "per-user";
threadReplies?: MatrixQaThreadRepliesMode;
};
export type MatrixQaThreadBindingsConfigOverrides = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnAcpSessions?: boolean;
spawnSubagentSessions?: boolean;
};
export type MatrixQaConfigOverrides = {
agentDefaults?: MatrixQaAgentDefaultsOverrides;
autoJoin?: MatrixQaAutoJoinMode;
autoJoinAllowlist?: string[];
blockStreaming?: boolean;
dm?: MatrixQaDmConfigOverrides;
encryption?: boolean;
groupAllowFrom?: string[];
groupAllowRoles?: MatrixQaActorRole[];
groupPolicy?: MatrixQaGroupPolicy;
groupsByKey?: Record<string, MatrixQaGroupConfigOverrides>;
replyToMode?: MatrixQaReplyToMode;
startupVerification?: "if-unverified" | "off";
streaming?: "off" | "partial" | "quiet" | boolean;
threadBindings?: MatrixQaThreadBindingsConfigOverrides;
threadReplies?: MatrixQaThreadRepliesMode;
toolProfile?: "coding" | "messaging" | "minimal";
};
export type MatrixQaConfigSnapshot = {
autoJoin: MatrixQaAutoJoinMode;
autoJoinAllowlist: string[];
blockStreaming: boolean;
dm: {
allowFrom: string[];
enabled: boolean;
policy: MatrixQaDmPolicy;
sessionScope: "per-room" | "per-user";
threadReplies: MatrixQaThreadRepliesMode;
};
encryption: boolean;
groupAllowFrom: string[];
groupPolicy: MatrixQaGroupPolicy;
groupsByKey: Record<string, MatrixQaGroupSnapshot>;
replyToMode: MatrixQaReplyToMode;
startupVerification?: "if-unverified" | "off";
streaming: MatrixQaStreamingMode;
threadBindings: MatrixQaThreadBindingsConfigOverrides;
threadReplies: MatrixQaThreadRepliesMode;
};
type MatrixQaGroupSnapshot = {
enabled: boolean;
requireMention: boolean;
roomId: string;
tools?: MatrixQaToolConfigOverrides;
};
type MatrixQaGroupEntry = Omit<MatrixQaGroupSnapshot, "roomId">;
type MatrixQaChannelConfig = NonNullable<OpenClawConfig["channels"]>["matrix"];
type MatrixQaChannelAccountConfig = NonNullable<
NonNullable<MatrixQaChannelConfig>["accounts"]
>[string];
type MatrixQaAccountDmConfig =
| { enabled: false }
| {
allowFrom: string[];
enabled: true;
policy: MatrixQaDmPolicy;
sessionScope?: "per-room" | "per-user";
threadReplies?: MatrixQaThreadRepliesMode;
};
function normalizeMatrixQaAllowlist(entries?: string[]) {
return [...new Set((entries ?? []).map((entry) => entry.trim()).filter(Boolean))];
}
function resolveMatrixQaGroupSnapshots(params: {
overrides?: MatrixQaConfigOverrides;
topology: MatrixQaProvisionedTopology;
}) {
const groupRooms = params.topology.rooms.filter((room) => room.kind === "group");
const groupsByKey = params.overrides?.groupsByKey ?? {};
const knownGroupKeys = new Set(groupRooms.map((room) => room.key));
for (const key of Object.keys(groupsByKey)) {
if (!knownGroupKeys.has(key)) {
throw new Error(`Matrix QA group override references unknown room key "${key}"`);
}
}
return Object.fromEntries(
groupRooms.map((room) => {
const override = groupsByKey[room.key];
return [
room.key,
{
roomId: room.roomId,
enabled: override?.enabled ?? true,
requireMention: override?.requireMention ?? room.requireMention,
...(override?.tools ? { tools: override.tools } : {}),
},
];
}),
);
}
function buildMatrixQaGroupEntries(
groupsByKey: MatrixQaConfigSnapshot["groupsByKey"],
): Record<string, MatrixQaGroupEntry> {
return Object.fromEntries(
Object.values(groupsByKey).map((group) => [
group.roomId,
{
enabled: group.enabled,
requireMention: group.requireMention,
...(group.tools ? { tools: group.tools } : {}),
},
]),
);
}
function resolveMatrixQaDmAllowFrom(params: {
driverUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}) {
if (params.overrides?.dm?.allowFrom) {
return normalizeMatrixQaAllowlist(params.overrides.dm.allowFrom);
}
const dmParticipantUserIds = params.topology.rooms
.filter((room) => room.kind === "dm")
.flatMap((room) => room.memberUserIds.filter((userId) => userId !== params.sutUserId));
const dmAllowFrom = [...new Set(dmParticipantUserIds)];
return dmAllowFrom.length > 0 ? dmAllowFrom : [params.driverUserId];
}
function resolveMatrixQaDmConfigSnapshot(params: {
driverUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}) {
const hasDmRooms = params.topology.rooms.some((room) => room.kind === "dm");
const dmOverrides = params.overrides?.dm;
const enabled = hasDmRooms || dmOverrides?.enabled === true;
return {
allowFrom: enabled ? resolveMatrixQaDmAllowFrom(params) : [],
enabled,
policy: dmOverrides?.policy ?? "allowlist",
sessionScope: dmOverrides?.sessionScope ?? "per-user",
threadReplies: dmOverrides?.threadReplies ?? params.overrides?.threadReplies ?? "inbound",
};
}
function resolveMatrixQaStreamingMode(
value: MatrixQaConfigOverrides["streaming"],
): MatrixQaStreamingMode {
if (value === true || value === "partial") {
return "partial";
}
if (value === "quiet") {
return "quiet";
}
return "off";
}
function resolveMatrixQaAutoJoinAllowlist(params: { overrides?: MatrixQaConfigOverrides }) {
if (params.overrides?.autoJoin !== "allowlist") {
return [];
}
return normalizeMatrixQaAllowlist(params.overrides.autoJoinAllowlist);
}
function resolveMatrixQaRoleAllowlist(params: {
roles?: MatrixQaActorRole[];
driverUserId: string;
observerUserId: string;
sutUserId: string;
}) {
const roleToUserId = {
driver: params.driverUserId,
observer: params.observerUserId,
sut: params.sutUserId,
} satisfies Record<MatrixQaActorRole, string>;
return (params.roles ?? []).map((role) => roleToUserId[role]);
}
function resolveMatrixQaGroupAllowFrom(params: {
driverUserId: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
}) {
const explicitAllowFrom = params.overrides?.groupAllowFrom;
const roleAllowFrom = resolveMatrixQaRoleAllowlist({
roles: params.overrides?.groupAllowRoles,
driverUserId: params.driverUserId,
observerUserId: params.observerUserId,
sutUserId: params.sutUserId,
});
if (explicitAllowFrom !== undefined || params.overrides?.groupAllowRoles !== undefined) {
return normalizeMatrixQaAllowlist([...(explicitAllowFrom ?? []), ...roleAllowFrom]);
}
return [params.driverUserId];
}
function formatMatrixQaBoolean(value: boolean) {
return value ? "true" : "false";
}
function buildMatrixQaAccountDmConfig(params: {
dmOverrides?: MatrixQaConfigOverrides["dm"];
snapshot: MatrixQaConfigSnapshot;
}): MatrixQaAccountDmConfig {
if (!params.snapshot.dm.enabled) {
return { enabled: false };
}
return {
allowFrom: params.snapshot.dm.allowFrom,
enabled: true,
policy: params.snapshot.dm.policy,
...(params.dmOverrides?.sessionScope ? { sessionScope: params.snapshot.dm.sessionScope } : {}),
...(params.dmOverrides?.threadReplies
? { threadReplies: params.snapshot.dm.threadReplies }
: {}),
};
}
function buildMatrixQaChannelAccountConfig(params: {
groups: Record<string, MatrixQaGroupEntry>;
homeserver: string;
overrides?: MatrixQaConfigOverrides;
snapshot: MatrixQaConfigSnapshot;
sutAccessToken: string;
sutDeviceId?: string;
sutUserId: string;
}): MatrixQaChannelAccountConfig {
const groupsConfig = Object.keys(params.groups).length > 0 ? { groups: params.groups } : {};
const autoJoinConfig =
params.snapshot.autoJoin !== "off" ? { autoJoin: params.snapshot.autoJoin } : {};
const autoJoinAllowlistConfig =
params.snapshot.autoJoin === "allowlist" && params.snapshot.autoJoinAllowlist.length > 0
? { autoJoinAllowlist: params.snapshot.autoJoinAllowlist }
: {};
const blockStreamingConfig =
params.overrides?.blockStreaming !== undefined
? { blockStreaming: params.snapshot.blockStreaming }
: {};
const streamingConfig =
params.overrides?.streaming !== undefined ? { streaming: params.overrides.streaming } : {};
const startupVerificationConfig =
params.snapshot.startupVerification !== undefined
? { startupVerification: params.snapshot.startupVerification }
: {};
const threadBindingsConfig =
params.overrides?.threadBindings !== undefined
? { threadBindings: params.snapshot.threadBindings }
: {};
return {
accessToken: params.sutAccessToken,
...(params.sutDeviceId ? { deviceId: params.sutDeviceId } : {}),
dm: buildMatrixQaAccountDmConfig({
dmOverrides: params.overrides?.dm,
snapshot: params.snapshot,
}),
enabled: true,
encryption: params.snapshot.encryption,
groupAllowFrom: params.snapshot.groupAllowFrom,
groupPolicy: params.snapshot.groupPolicy,
...groupsConfig,
homeserver: params.homeserver,
network: {
dangerouslyAllowPrivateNetwork: true,
},
replyToMode: params.snapshot.replyToMode,
...startupVerificationConfig,
...threadBindingsConfig,
threadReplies: params.snapshot.threadReplies,
userId: params.sutUserId,
...autoJoinConfig,
...autoJoinAllowlistConfig,
...blockStreamingConfig,
...streamingConfig,
};
}
export function buildMatrixQaConfigSnapshot(params: {
driverUserId: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}): MatrixQaConfigSnapshot {
return {
autoJoin: params.overrides?.autoJoin ?? "off",
autoJoinAllowlist: resolveMatrixQaAutoJoinAllowlist(params),
blockStreaming: params.overrides?.blockStreaming ?? false,
dm: resolveMatrixQaDmConfigSnapshot(params),
encryption: params.overrides?.encryption ?? false,
groupAllowFrom: resolveMatrixQaGroupAllowFrom(params),
groupPolicy: params.overrides?.groupPolicy ?? "allowlist",
groupsByKey: resolveMatrixQaGroupSnapshots({
overrides: params.overrides,
topology: params.topology,
}),
replyToMode: params.overrides?.replyToMode ?? "off",
startupVerification: params.overrides?.startupVerification,
streaming: resolveMatrixQaStreamingMode(params.overrides?.streaming),
threadBindings: { ...params.overrides?.threadBindings },
threadReplies: params.overrides?.threadReplies ?? "inbound",
};
}
export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot) {
return [
`replyToMode=${snapshot.replyToMode}`,
`threadReplies=${snapshot.threadReplies}`,
`dm.enabled=${formatMatrixQaBoolean(snapshot.dm.enabled)}`,
`dm.policy=${snapshot.dm.policy}`,
`dm.sessionScope=${snapshot.dm.sessionScope}`,
`dm.threadReplies=${snapshot.dm.threadReplies}`,
`streaming=${snapshot.streaming}`,
`blockStreaming=${formatMatrixQaBoolean(snapshot.blockStreaming)}`,
`autoJoin=${snapshot.autoJoin}`,
`encryption=${formatMatrixQaBoolean(snapshot.encryption)}`,
`startupVerification=${snapshot.startupVerification ?? "<default>"}`,
`threadBindings.enabled=${snapshot.threadBindings.enabled ?? "<default>"}`,
`threadBindings.spawnSubagentSessions=${snapshot.threadBindings.spawnSubagentSessions ?? "<default>"}`,
].join(", ");
}
export function buildMatrixQaConfig(
baseCfg: OpenClawConfig,
params: {
driverUserId: string;
homeserver: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutAccessToken: string;
sutAccountId: string;
sutDeviceId?: string;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
},
): OpenClawConfig {
const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "matrix"])];
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: params.driverUserId,
observerUserId: params.observerUserId,
overrides: params.overrides,
sutUserId: params.sutUserId,
topology: params.topology,
});
const groups = buildMatrixQaGroupEntries(snapshot.groupsByKey);
return {
...baseCfg,
...(params.overrides?.toolProfile
? {
tools: {
...baseCfg.tools,
profile: params.overrides.toolProfile,
},
}
: {}),
...(params.overrides?.agentDefaults
? {
agents: {
...baseCfg.agents,
defaults: {
...baseCfg.agents?.defaults,
...params.overrides.agentDefaults,
},
},
}
: {}),
plugins: {
...baseCfg.plugins,
allow: pluginAllow,
entries: {
...baseCfg.plugins?.entries,
matrix: { enabled: true },
},
},
channels: {
...baseCfg.channels,
matrix: {
...baseCfg.channels?.matrix,
enabled: true,
defaultAccount: params.sutAccountId,
accounts: {
...baseCfg.channels?.matrix?.accounts,
[params.sutAccountId]: buildMatrixQaChannelAccountConfig({
groups,
homeserver: params.homeserver,
overrides: params.overrides,
snapshot,
sutAccessToken: params.sutAccessToken,
sutDeviceId: params.sutDeviceId,
sutUserId: params.sutUserId,
}),
},
},
},
};
}

View file

@ -0,0 +1,60 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { __testing } from "./e2ee-client.js";
describe("matrix qa e2ee client storage", () => {
it("filters receipt noise without suppressing room state or timeline events", () => {
expect(__testing.MATRIX_QA_E2EE_SYNC_FILTER).toEqual({
room: {
ephemeral: { not_types: ["m.receipt"] },
},
});
});
it("shares persisted crypto by actor and scopes sync replay by scenario", () => {
const first = __testing.buildMatrixQaE2eeStoragePaths({
actorId: "driver",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
scenarioId: "matrix-e2ee-basic-reply",
});
const second = __testing.buildMatrixQaE2eeStoragePaths({
actorId: "driver",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
scenarioId: "matrix-e2ee-qr-verification",
});
expect(first.accountDir).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"account",
),
);
expect(first.cryptoDatabasePrefix).toBe(second.cryptoDatabasePrefix);
expect(first.recoveryKeyPath).toBe(path.join(first.accountDir, "recovery-key.json"));
expect(first.storagePath).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"scenarios",
"matrix-e2ee-basic-reply",
"sync-store.json",
),
);
expect(second.storagePath).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"scenarios",
"matrix-e2ee-qr-verification",
"sync-store.json",
),
);
});
});

View file

@ -0,0 +1,408 @@
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type {
EncryptedFile,
MatrixDeviceVerificationStatus,
MatrixClient,
MatrixOwnDeviceDeleteResult,
MatrixOwnDeviceInfo,
MatrixRawEvent,
MatrixRecoveryKeyVerificationResult,
MatrixRoomKeyBackupResetResult,
MatrixRoomKeyBackupRestoreResult,
MatrixVerificationBootstrapResult,
MatrixVerificationMethod,
MatrixVerificationSummary,
MessageEventContent,
} from "@openclaw/matrix/test-api.js";
import { buildMatrixQaMessageContent } from "./client.js";
import { normalizeMatrixQaObservedEvent } from "./events.js";
import type { MatrixQaObservedEvent } from "./events.js";
import type { MatrixQaRoomEventWaitResult } from "./sync.js";
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}`;
type MatrixQaE2eeRuntime = typeof import("@openclaw/matrix/test-api.js");
type MatrixQaE2eeClientParams = {
accessToken: string;
actorId: MatrixQaE2eeActorId;
baseUrl: string;
deviceId?: string;
outputDir: string;
password?: string;
scenarioId: string;
timeoutMs: number;
userId: string;
};
const MATRIX_QA_E2EE_SYNC_FILTER = {
room: {
ephemeral: { not_types: ["m.receipt"] },
},
};
export type MatrixQaE2eeScenarioClient = {
acceptVerification(id: string): Promise<MatrixVerificationSummary>;
bootstrapOwnDeviceVerification(params?: {
forceResetCrossSigning?: boolean;
recoveryKey?: string;
}): Promise<MatrixVerificationBootstrapResult>;
confirmVerificationReciprocateQr(id: string): Promise<MatrixVerificationSummary>;
confirmVerificationSas(id: string): Promise<MatrixVerificationSummary>;
deleteOwnDevices(deviceIds: string[]): Promise<MatrixOwnDeviceDeleteResult>;
generateVerificationQr(id: string): Promise<{ qrDataBase64: string }>;
getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<MatrixDeviceVerificationStatus>;
getRecoveryKey(): Promise<{
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null>;
listOwnDevices(): Promise<MatrixOwnDeviceInfo[]>;
listVerifications(): Promise<MatrixVerificationSummary[]>;
prime(): Promise<string | undefined>;
requestVerification(params: {
deviceId?: string;
ownUser?: boolean;
roomId?: string;
userId?: string;
}): Promise<MatrixVerificationSummary>;
resetRoomKeyBackup(): Promise<MatrixRoomKeyBackupResetResult>;
restoreRoomKeyBackup(params?: {
recoveryKey?: string;
}): Promise<MatrixRoomKeyBackupRestoreResult>;
scanVerificationQr(id: string, qrDataBase64: string): Promise<MatrixVerificationSummary>;
verifyWithRecoveryKey(rawRecoveryKey: string): Promise<MatrixRecoveryKeyVerificationResult>;
sendTextMessage(opts: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}): Promise<string>;
sendNoticeMessage(opts: {
body: string;
mentionUserIds?: string[];
roomId: string;
}): Promise<string>;
sendImageMessage(opts: {
body: string;
buffer: Buffer;
contentType: string;
fileName: string;
mentionUserIds?: string[];
roomId: string;
}): Promise<string>;
startVerification(
id: string,
method?: MatrixVerificationMethod,
): Promise<MatrixVerificationSummary>;
stop(): Promise<void>;
waitForOptionalRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<MatrixQaRoomEventWaitResult>;
waitForRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<{
event: MatrixQaObservedEvent;
since?: string;
}>;
};
async function loadMatrixQaE2eeRuntime(): Promise<MatrixQaE2eeRuntime> {
const { loadQaRunnerBundledPluginTestApi } =
await import("openclaw/plugin-sdk/qa-runner-runtime");
return loadQaRunnerBundledPluginTestApi<MatrixQaE2eeRuntime>("matrix");
}
function buildMatrixQaE2eeStoragePaths(params: {
actorId: MatrixQaE2eeActorId;
outputDir: string;
scenarioId: string;
}) {
const rootDir = path.join(params.outputDir, "matrix-e2ee", "accounts", params.actorId);
const accountDir = path.join(rootDir, "account");
const scenarioKey = params.scenarioId.replace(/[^A-Za-z0-9_-]/g, "-").slice(-80);
const runKey = path
.basename(params.outputDir)
.replace(/[^A-Za-z0-9_-]/g, "-")
.slice(-80);
const actorKey = params.actorId.replace(/[^A-Za-z0-9_-]/g, "-").slice(-40);
return {
accountDir,
cryptoDatabasePrefix: `qa-matrix-${runKey || "run"}-${actorKey || "actor"}`,
idbSnapshotPath: path.join(accountDir, "crypto-idb-snapshot.json"),
recoveryKeyPath: path.join(accountDir, "recovery-key.json"),
rootDir,
storagePath: path.join(rootDir, "scenarios", scenarioKey || "scenario", "sync-store.json"),
};
}
async function prepareMatrixQaE2eeStorage(params: {
actorId: MatrixQaE2eeActorId;
outputDir: string;
scenarioId: string;
}) {
const storage = buildMatrixQaE2eeStoragePaths(params);
await fs.mkdir(storage.rootDir, { recursive: true });
await fs.mkdir(storage.accountDir, { recursive: true });
await fs.mkdir(path.dirname(storage.storagePath), { recursive: true });
await fs.writeFile(storage.idbSnapshotPath, "[]\n", { flag: "wx" }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
});
return storage;
}
async function createMatrixQaE2eeMatrixClient(params: MatrixQaE2eeClientParams) {
const runtime = await loadMatrixQaE2eeRuntime();
const storage = await prepareMatrixQaE2eeStorage({
actorId: params.actorId,
outputDir: params.outputDir,
scenarioId: params.scenarioId,
});
return new runtime.MatrixClient(params.baseUrl, params.accessToken, {
autoBootstrapCrypto: false,
cryptoDatabasePrefix: storage.cryptoDatabasePrefix,
deviceId: params.deviceId,
encryption: true,
idbSnapshotPath: storage.idbSnapshotPath,
localTimeoutMs: Math.max(10_000, params.timeoutMs),
password: params.password,
recoveryKeyPath: storage.recoveryKeyPath,
ssrfPolicy: { allowPrivateNetwork: true },
storagePath: storage.storagePath,
syncFilter: MATRIX_QA_E2EE_SYNC_FILTER,
userId: params.userId,
});
}
function findMatrixQaObservedEventMatch(params: {
cursorIndex: number;
events: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
}) {
for (let index = params.cursorIndex; index < params.events.length; index += 1) {
const event = params.events[index];
if (event?.roomId !== params.roomId) {
continue;
}
if (params.predicate(event)) {
return {
event,
nextCursorIndex: index + 1,
};
}
}
return undefined;
}
export async function createMatrixQaE2eeScenarioClient(
params: MatrixQaE2eeClientParams & {
observedEvents: MatrixQaObservedEvent[];
},
): Promise<MatrixQaE2eeScenarioClient> {
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
const localEvents: MatrixQaObservedEvent[] = [];
const verificationSummaries: MatrixVerificationSummary[] = [];
const observedEventIds = new Set<string>();
let cursorIndex = 0;
const recordEvent = (roomId: string, event: MatrixRawEvent) => {
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
if (!normalized || observedEventIds.has(normalized.eventId)) {
return;
}
observedEventIds.add(normalized.eventId);
localEvents.push(normalized);
params.observedEvents.push(normalized);
};
client.on("room.message", recordEvent);
const recordVerificationSummary = (summary: MatrixVerificationSummary) => {
verificationSummaries.push(summary);
};
client.on("verification.summary", recordVerificationSummary);
try {
await client.start({ readyTimeoutMs: Math.min(45_000, Math.max(15_000, params.timeoutMs)) });
} catch (error) {
await client.stopAndPersist().catch(() => undefined);
throw error;
}
const prime = async () => {
cursorIndex = Math.max(cursorIndex, localEvents.length);
return `e2ee:${cursorIndex}`;
};
const waitForOptionalRoomEvent: MatrixQaE2eeScenarioClient["waitForOptionalRoomEvent"] = async (
waitParams,
) => {
const startSince = `e2ee:${cursorIndex}`;
const startedAt = Date.now();
let scanIndex = cursorIndex;
while (Date.now() - startedAt < waitParams.timeoutMs) {
const matched = findMatrixQaObservedEventMatch({
cursorIndex: scanIndex,
events: localEvents,
predicate: waitParams.predicate,
roomId: waitParams.roomId,
});
if (matched) {
cursorIndex = Math.max(cursorIndex, matched.nextCursorIndex);
return {
event: matched.event,
matched: true,
since: `e2ee:${cursorIndex}`,
};
}
scanIndex = localEvents.length;
await sleep(Math.min(250, Math.max(25, waitParams.timeoutMs - (Date.now() - startedAt))));
}
cursorIndex = Math.max(cursorIndex, scanIndex);
return {
matched: false,
since: startSince,
};
};
const requireCrypto = () => {
if (!client.crypto) {
throw new Error("Matrix E2EE scenario requires Matrix crypto");
}
return client.crypto;
};
return {
async acceptVerification(id) {
return await requireCrypto().acceptVerification(id);
},
async bootstrapOwnDeviceVerification(opts) {
return await client.bootstrapOwnDeviceVerification(opts);
},
async confirmVerificationReciprocateQr(id) {
return await requireCrypto().confirmVerificationReciprocateQr(id);
},
async confirmVerificationSas(id) {
return await requireCrypto().confirmVerificationSas(id);
},
async deleteOwnDevices(deviceIds) {
return await client.deleteOwnDevices(deviceIds);
},
async generateVerificationQr(id) {
return await requireCrypto().generateVerificationQr(id);
},
async getDeviceVerificationStatus(userId, deviceId) {
return await client.getDeviceVerificationStatus(userId, deviceId);
},
async getRecoveryKey() {
return await requireCrypto().getRecoveryKey();
},
async listOwnDevices() {
return await client.listOwnDevices();
},
async listVerifications() {
const current = await requireCrypto().listVerifications();
return [...verificationSummaries, ...current].toSorted((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
);
},
prime,
async requestVerification(opts) {
return await requireCrypto().requestVerification(opts);
},
async resetRoomKeyBackup() {
return await client.resetRoomKeyBackup();
},
async restoreRoomKeyBackup(opts) {
return await client.restoreRoomKeyBackup(opts);
},
async scanVerificationQr(id, qrDataBase64) {
return await requireCrypto().scanVerificationQr(id, qrDataBase64);
},
async sendTextMessage(opts) {
return await client.sendMessage(
opts.roomId,
buildMatrixQaMessageContent(opts) as MessageEventContent,
);
},
async sendNoticeMessage(opts) {
return await client.sendMessage(opts.roomId, {
...buildMatrixQaMessageContent(opts),
msgtype: "m.notice",
} as MessageEventContent);
},
async sendImageMessage(opts) {
const encrypted = await requireCrypto().encryptMedia(opts.buffer);
const contentUri = await client.uploadContent(
encrypted.buffer,
opts.contentType,
opts.fileName,
);
const file: EncryptedFile = { url: contentUri, ...encrypted.file };
return await client.sendMessage(opts.roomId, {
...buildMatrixQaMessageContent({
body: opts.body,
mentionUserIds: opts.mentionUserIds,
}),
file,
filename: opts.fileName,
info: {
mimetype: opts.contentType,
size: opts.buffer.byteLength,
},
msgtype: "m.image",
} as MessageEventContent);
},
async startVerification(id, method) {
return await requireCrypto().startVerification(id, method);
},
async stop() {
client.off("room.message", recordEvent);
client.off("verification.summary", recordVerificationSummary);
await client.drainPendingDecryptions().catch(() => undefined);
await client.stopAndPersist();
},
waitForOptionalRoomEvent,
async waitForRoomEvent(waitParams) {
const result = await waitForOptionalRoomEvent(waitParams);
if (result.matched) {
return {
event: result.event,
since: result.since,
};
}
throw new Error(`timed out after ${waitParams.timeoutMs}ms waiting for Matrix E2EE event`);
},
async verifyWithRecoveryKey(rawRecoveryKey) {
return await client.verifyWithRecoveryKey(rawRecoveryKey);
},
};
}
export async function runMatrixQaE2eeBootstrap(
params: MatrixQaE2eeClientParams,
): Promise<MatrixVerificationBootstrapResult> {
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
try {
return await client.bootstrapOwnDeviceVerification();
} finally {
await client.stopAndPersist().catch(() => undefined);
}
}
export const __testing = {
MATRIX_QA_E2EE_SYNC_FILTER,
buildMatrixQaE2eeStoragePaths,
findMatrixQaObservedEventMatch,
};

View file

@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import { normalizeMatrixQaObservedEvent } from "./events.js";
describe("matrix observed event normalization", () => {
it("normalizes message events with thread metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
origin_server_ts: 1_700_000_000_000,
content: {
body: "hello",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$driver",
},
},
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
originServerTs: 1_700_000_000_000,
body: "hello",
msgtype: "m.text",
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
mentions: {
userIds: ["@sut:matrix-qa.test"],
},
});
});
it("classifies Matrix notices separately from regular messages", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$notice",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "notice",
msgtype: "m.notice",
},
}),
).toEqual(
expect.objectContaining({
kind: "notice",
eventId: "$notice",
msgtype: "m.notice",
type: "m.room.message",
}),
);
});
it("prefers m.new_content text for Matrix replacement events", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$replace",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "* finalized",
msgtype: "m.text",
"m.new_content": {
body: "finalized",
msgtype: "m.text",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$draft",
},
},
}),
).toEqual(
expect.objectContaining({
kind: "message",
eventId: "$replace",
body: "finalized",
msgtype: "m.text",
relatesTo: {
eventId: "$draft",
relType: "m.replace",
},
}),
);
});
it("normalizes Matrix reaction events with target metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
origin_server_ts: 1_700_000_000_000,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg",
key: "👍",
},
},
}),
).toEqual({
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
originServerTs: 1_700_000_000_000,
relatesTo: {
eventId: "$msg",
relType: "m.annotation",
},
reaction: {
eventId: "$msg",
key: "👍",
},
});
});
it("normalizes Matrix image messages with attachment metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$image",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "Protocol note: generated the QA lighthouse image successfully.",
filename: "qa-lighthouse.png",
msgtype: "m.image",
},
}),
).toEqual(
expect.objectContaining({
kind: "message",
eventId: "$image",
msgtype: "m.image",
attachment: {
kind: "image",
caption: "Protocol note: generated the QA lighthouse image successfully.",
filename: "qa-lighthouse.png",
},
}),
);
});
it("treats filename-like Matrix media bodies as attachment filenames", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$image",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "qa-lighthouse.png",
msgtype: "m.image",
},
}),
).toEqual(
expect.objectContaining({
attachment: {
kind: "image",
filename: "qa-lighthouse.png",
},
}),
);
});
it("normalizes membership events with explicit membership kind", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$membership",
sender: "@driver:matrix-qa.test",
state_key: "@sut:matrix-qa.test",
type: "m.room.member",
content: {
membership: "leave",
},
}),
).toEqual(
expect.objectContaining({
kind: "membership",
eventId: "$membership",
membership: "leave",
stateKey: "@sut:matrix-qa.test",
type: "m.room.member",
}),
);
});
it("classifies Matrix redactions without needing raw event inspection", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
content: {},
}),
).toEqual(
expect.objectContaining({
kind: "redaction",
eventId: "$redaction",
type: "m.room.redaction",
}),
);
});
});

View file

@ -0,0 +1,226 @@
export type MatrixQaRoomEvent = {
content?: Record<string, unknown>;
event_id?: string;
origin_server_ts?: number;
sender?: string;
state_key?: string;
type?: string;
};
export type MatrixQaObservedEventKind =
| "membership"
| "message"
| "notice"
| "redaction"
| "reaction"
| "room-event";
export type MatrixQaObservedEventAttachment = {
caption?: string;
filename?: string;
kind: "audio" | "file" | "image" | "sticker" | "video";
};
export type MatrixQaObservedEvent = {
kind: MatrixQaObservedEventKind;
roomId: string;
eventId: string;
sender?: string;
stateKey?: string;
type: string;
originServerTs?: number;
body?: string;
formattedBody?: string;
msgtype?: string;
membership?: string;
relatesTo?: {
eventId?: string;
inReplyToId?: string;
isFallingBack?: boolean;
relType?: string;
};
mentions?: {
room?: boolean;
userIds?: string[];
};
reaction?: {
eventId?: string;
key?: string;
};
attachment?: MatrixQaObservedEventAttachment;
};
function normalizeMentionUserIds(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: undefined;
}
function resolveMatrixQaMessageContent(
content: Record<string, unknown>,
relatesTo: Record<string, unknown> | null,
) {
const newContentRaw = content["m.new_content"];
const newContent =
typeof newContentRaw === "object" && newContentRaw !== null
? (newContentRaw as Record<string, unknown>)
: null;
if (relatesTo?.rel_type === "m.replace" && newContent) {
return newContent;
}
return content;
}
function resolveMatrixQaObservedEventKind(params: { msgtype?: string; type: string }) {
if (params.type === "m.reaction") {
return "reaction" as const;
}
if (params.type === "m.room.redaction") {
return "redaction" as const;
}
if (params.type === "m.room.member") {
return "membership" as const;
}
if (params.type === "m.room.message") {
return params.msgtype === "m.notice" ? ("notice" as const) : ("message" as const);
}
return "room-event" as const;
}
function resolveMatrixQaAttachmentKind(msgtype: string | undefined) {
switch (msgtype) {
case "m.audio":
return "audio" as const;
case "m.file":
return "file" as const;
case "m.image":
return "image" as const;
case "m.sticker":
return "sticker" as const;
case "m.video":
return "video" as const;
default:
return undefined;
}
}
function isLikelyMatrixQaFilenameBody(value: string) {
return !value.includes("\n") && /\.[a-z0-9][a-z0-9._-]{0,24}$/i.test(value);
}
function resolveMatrixQaAttachmentSummary(params: {
body?: string;
filename?: string;
msgtype?: string;
}): MatrixQaObservedEventAttachment | undefined {
const kind = resolveMatrixQaAttachmentKind(params.msgtype);
if (!kind) {
return undefined;
}
const body = params.body?.trim() ?? "";
const explicitFilename = params.filename?.trim() ?? "";
const inferredFilename =
!explicitFilename && body && isLikelyMatrixQaFilenameBody(body) ? body : "";
const filename = explicitFilename || inferredFilename;
const caption = body && body !== filename ? body : "";
return {
kind,
...(caption ? { caption } : {}),
...(filename ? { filename } : {}),
};
}
export function normalizeMatrixQaObservedEvent(
roomId: string,
event: MatrixQaRoomEvent,
): MatrixQaObservedEvent | null {
const eventId = event.event_id?.trim();
const type = event.type?.trim();
if (!eventId || !type) {
return null;
}
const content = event.content ?? {};
const msgtype = typeof content.msgtype === "string" ? content.msgtype : undefined;
const relatesToRaw = content["m.relates_to"];
const relatesTo =
typeof relatesToRaw === "object" && relatesToRaw !== null
? (relatesToRaw as Record<string, unknown>)
: null;
const inReplyToRaw = relatesTo?.["m.in_reply_to"];
const inReplyTo =
typeof inReplyToRaw === "object" && inReplyToRaw !== null
? (inReplyToRaw as Record<string, unknown>)
: null;
const messageContent = resolveMatrixQaMessageContent(content, relatesTo);
const normalizedMsgtype =
typeof messageContent.msgtype === "string" ? messageContent.msgtype : msgtype;
const normalizedFilename =
typeof messageContent.filename === "string"
? messageContent.filename
: typeof content.filename === "string"
? content.filename
: undefined;
const mentionsRaw = messageContent["m.mentions"] ?? content["m.mentions"];
const mentions =
typeof mentionsRaw === "object" && mentionsRaw !== null
? (mentionsRaw as Record<string, unknown>)
: null;
const mentionUserIds = normalizeMentionUserIds(mentions?.user_ids);
const reactionKey =
type === "m.reaction" && typeof relatesTo?.key === "string" ? relatesTo.key : undefined;
const reactionEventId =
type === "m.reaction" && typeof relatesTo?.event_id === "string"
? relatesTo.event_id
: undefined;
const attachment = resolveMatrixQaAttachmentSummary({
body: typeof messageContent.body === "string" ? messageContent.body : undefined,
filename: normalizedFilename,
msgtype: normalizedMsgtype,
});
return {
kind: resolveMatrixQaObservedEventKind({ msgtype: normalizedMsgtype, type }),
roomId,
eventId,
sender: typeof event.sender === "string" ? event.sender : undefined,
stateKey: typeof event.state_key === "string" ? event.state_key : undefined,
type,
originServerTs:
typeof event.origin_server_ts === "number" ? Math.floor(event.origin_server_ts) : undefined,
body: typeof messageContent.body === "string" ? messageContent.body : undefined,
formattedBody:
typeof messageContent.formatted_body === "string" ? messageContent.formatted_body : undefined,
msgtype: normalizedMsgtype,
membership: typeof content.membership === "string" ? content.membership : undefined,
...(relatesTo
? {
relatesTo: {
eventId: typeof relatesTo.event_id === "string" ? relatesTo.event_id : undefined,
inReplyToId: typeof inReplyTo?.event_id === "string" ? inReplyTo.event_id : undefined,
isFallingBack:
typeof relatesTo.is_falling_back === "boolean"
? relatesTo.is_falling_back
: undefined,
relType: typeof relatesTo.rel_type === "string" ? relatesTo.rel_type : undefined,
},
}
: {}),
...(mentions
? {
mentions: {
...(mentions.room === true ? { room: true } : {}),
...(mentionUserIds ? { userIds: mentionUserIds } : {}),
},
}
: {}),
...(reactionEventId || reactionKey
? {
reaction: {
...(reactionEventId ? { eventId: reactionEventId } : {}),
...(reactionKey ? { key: reactionKey } : {}),
},
}
: {}),
...(attachment ? { attachment } : {}),
};
}

View file

@ -0,0 +1,127 @@
import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { startMatrixQaFaultProxy, type MatrixQaFaultProxy } from "./fault-proxy.js";
const servers: Array<{ close(): Promise<void> }> = [];
async function startTargetServer() {
const requests: Array<{
authorization?: string;
body: string;
method: string;
url: string;
}> = [];
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
requests.push({
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
body: Buffer.concat(chunks).toString("utf8"),
method: req.method ?? "GET",
url: req.url ?? "/",
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ forwarded: true }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("target server did not bind to a TCP port");
}
const handle = {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
requests,
};
servers.push(handle);
return handle;
}
describe("Matrix QA fault proxy", () => {
let proxy: MatrixQaFaultProxy | undefined;
afterEach(async () => {
await proxy?.stop();
proxy = undefined;
while (servers.length > 0) {
await servers.pop()?.close();
}
});
it("faults matching Matrix requests and forwards everything else", async () => {
const target = await startTargetServer();
proxy = await startMatrixQaFaultProxy({
targetBaseUrl: target.baseUrl,
rules: [
{
id: "room-key-backup-version-unavailable",
match: (request) =>
request.method === "GET" &&
request.path === "/_matrix/client/v3/room_keys/version" &&
request.bearerToken === "driver-token",
response: () => ({
body: {
errcode: "M_NOT_FOUND",
error: "No current key backup",
},
status: 404,
}),
},
],
});
const faulted = await fetch(`${proxy.baseUrl}/_matrix/client/v3/room_keys/version`, {
headers: { authorization: "Bearer driver-token" },
});
expect(faulted.status).toBe(404);
await expect(faulted.json()).resolves.toEqual({
errcode: "M_NOT_FOUND",
error: "No current key backup",
});
const forwarded = await fetch(`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0`, {
body: JSON.stringify({ ok: true }),
headers: {
authorization: "Bearer driver-token",
"content-type": "application/json",
},
method: "POST",
});
expect(forwarded.status).toBe(200);
await expect(forwarded.json()).resolves.toEqual({ forwarded: true });
expect(proxy.hits()).toEqual([
{
method: "GET",
path: "/_matrix/client/v3/room_keys/version",
ruleId: "room-key-backup-version-unavailable",
},
]);
expect(target.requests).toEqual([
{
authorization: "Bearer driver-token",
body: '{"ok":true}',
method: "POST",
url: "/_matrix/client/v3/sync?timeout=0",
},
]);
});
});

View file

@ -0,0 +1,226 @@
import {
createServer,
type IncomingHttpHeaders,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
export type MatrixQaFaultProxyRequest = {
bearerToken?: string;
headers: IncomingHttpHeaders;
method: string;
path: string;
search: string;
};
export type MatrixQaFaultProxyResponse = {
body?: unknown;
headers?: Record<string, string>;
status: number;
};
export type MatrixQaFaultProxyRule = {
id: string;
match(request: MatrixQaFaultProxyRequest): boolean;
response(request: MatrixQaFaultProxyRequest): MatrixQaFaultProxyResponse;
};
export type MatrixQaFaultProxyHit = {
method: string;
path: string;
ruleId: string;
};
export type MatrixQaFaultProxy = {
baseUrl: string;
hits(): MatrixQaFaultProxyHit[];
stop(): Promise<void>;
};
function normalizeHeaderValue(value: string | string[] | undefined) {
if (Array.isArray(value)) {
return value.join(", ");
}
return value;
}
function extractBearerToken(headers: IncomingHttpHeaders) {
const value = normalizeHeaderValue(headers.authorization)?.trim();
const match = /^Bearer\s+(.+)$/i.exec(value ?? "");
return match?.[1];
}
function buildFetchHeaders(headers: IncomingHttpHeaders) {
const result = new Headers();
for (const [key, rawValue] of Object.entries(headers)) {
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase()) || key.toLowerCase() === "host") {
continue;
}
const value = normalizeHeaderValue(rawValue);
if (value !== undefined) {
result.set(key, value);
}
}
return result;
}
async function readRequestBody(req: IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
function bufferToArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function writeJsonResponse(res: ServerResponse, response: MatrixQaFaultProxyResponse) {
const body = response.body === undefined ? "" : JSON.stringify(response.body);
res.writeHead(response.status, {
"content-type": "application/json",
...response.headers,
});
res.end(body);
}
async function forwardMatrixQaFaultProxyRequest(params: {
body: Buffer;
req: IncomingMessage;
targetUrl: URL;
}) {
const method = params.req.method ?? "GET";
const init: RequestInit = {
headers: buildFetchHeaders(params.req.headers),
method,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD") {
init.body = bufferToArrayBuffer(params.body);
}
const { response, release } = await fetchWithSsrFGuard({
url: params.targetUrl.toString(),
init,
policy: { allowPrivateNetwork: true },
auditContext: "qa-matrix-fault-proxy-forward",
});
try {
return {
body: Buffer.from(await response.arrayBuffer()),
headers: response.headers,
status: response.status,
};
} finally {
await release();
}
}
function writeForwardedResponse(
res: ServerResponse,
response: Awaited<ReturnType<typeof forwardMatrixQaFaultProxyRequest>>,
) {
const headers: Record<string, string> = {};
for (const [key, value] of response.headers) {
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
headers[key] = value;
}
}
res.writeHead(response.status, headers);
res.end(response.body);
}
export async function startMatrixQaFaultProxy(params: {
rules: MatrixQaFaultProxyRule[];
targetBaseUrl: string;
}): Promise<MatrixQaFaultProxy> {
const targetBaseUrl = new URL(params.targetBaseUrl);
const hits: MatrixQaFaultProxyHit[] = [];
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
const path = requestUrl.pathname;
const bearerToken = extractBearerToken(req.headers);
const request: MatrixQaFaultProxyRequest = {
...(bearerToken ? { bearerToken } : {}),
headers: req.headers,
method: req.method ?? "GET",
path,
search: requestUrl.search,
};
const body = await readRequestBody(req);
const rule = params.rules.find((candidate) => candidate.match(request));
if (rule) {
hits.push({
method: request.method,
path: request.path,
ruleId: rule.id,
});
writeJsonResponse(res, rule.response(request));
return;
}
writeForwardedResponse(
res,
await forwardMatrixQaFaultProxyRequest({
body,
req,
targetUrl: requestUrl,
}),
);
} catch (error) {
writeJsonResponse(res, {
body: {
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
status: 502,
});
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Matrix QA fault proxy did not bind to a TCP port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
hits: () => [...hits],
stop: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}

View file

@ -0,0 +1,272 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { __testing, startMatrixQaHarness, writeMatrixQaHarnessFiles } from "./harness.runtime.js";
describe("matrix harness runtime", () => {
it("writes a pinned Tuwunel compose file and redacted manifest", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await writeMatrixQaHarnessFiles({
outputDir,
homeserverPort: 28008,
registrationToken: "secret-token",
serverName: "matrix-qa.test",
});
const compose = await readFile(result.composeFile, "utf8");
const manifest = JSON.parse(await readFile(result.manifestPath, "utf8")) as {
image: string;
serverName: string;
homeserverPort: number;
composeFile: string;
};
expect(compose).toContain(`image: ${__testing.MATRIX_QA_DEFAULT_IMAGE}`);
expect(compose).toContain(' - "127.0.0.1:28008:8008"');
expect(compose).toContain('TUWUNEL_ALLOW_ENCRYPTION: "true"');
expect(compose).toContain('TUWUNEL_ALLOW_REGISTRATION: "true"');
expect(compose).toContain('TUWUNEL_REGISTRATION_TOKEN: "secret-token"');
expect(compose).toContain('TUWUNEL_SERVER_NAME: "matrix-qa.test"');
expect(manifest).toEqual({
image: __testing.MATRIX_QA_DEFAULT_IMAGE,
serverName: "matrix-qa.test",
homeserverPort: 28008,
composeFile: path.join(outputDir, "docker-compose.matrix-qa.yml"),
dataDir: path.join(outputDir, "data"),
});
expect(result.registrationToken).toBe("secret-token");
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("starts the harness, waits for versions, and exposes a stop command", async () => {
const calls: string[] = [];
const fetchCalls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
if (args.join(" ").includes("ps --format json")) {
return { stdout: '[{"State":"running"}]\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return { ok: true };
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
);
expect(calls).toEqual([
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans @/repo/openclaw`,
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml up -d @/repo/openclaw`,
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps --format json matrix-qa-homeserver @/repo/openclaw`,
]);
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
]);
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
expect(result.stopCommand).toBe(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans`,
);
await result.restartService();
expect(calls).toContain(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml restart matrix-qa-homeserver @/repo/openclaw`,
);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("treats empty Docker health fields as a fallback to running state", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{
async runCommand(_command, args) {
if (args.join(" ").includes("ps --format json")) {
return { stdout: '{"Health":"","State":"running"}\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
);
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("falls back to the container IP when the host port is unreachable", async () => {
const calls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
const rendered = args.join(" ");
if (rendered.includes("ps --format json")) {
return { stdout: '{"State":"running"}\n', stderr: "" };
}
if (rendered.includes("ps -q")) {
return { stdout: "container-123\n", stderr: "" };
}
if (rendered.includes("inspect --format")) {
return { stdout: "172.18.0.10\n", stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async (input: string) => ({
ok: input.startsWith("http://172.18.0.10:8008/"),
})),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
);
expect(result.baseUrl).toBe("http://172.18.0.10:8008/");
expect(calls).toContain(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps -q matrix-qa-homeserver @/repo/openclaw`,
);
expect(calls).toContain(
"docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} container-123 @/repo/openclaw",
);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("keeps the host URL when the container IP is also unreachable", async () => {
const fetchCalls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{
async runCommand(_command, args) {
const rendered = args.join(" ");
if (rendered.includes("ps --format json")) {
return { stdout: '{"State":"running"}\n', stderr: "" };
}
if (rendered.includes("ps -q")) {
return { stdout: "container-123\n", stderr: "" };
}
if (rendered.includes("inspect --format")) {
return { stdout: "172.18.0.10\n", stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return {
ok:
input === "http://127.0.0.1:28008/_matrix/client/versions" &&
fetchCalls.filter((url) => url === input).length > 1,
};
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
);
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
]);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("keeps probing the container URL until it becomes reachable", async () => {
const fetchCalls: string[] = [];
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{
async runCommand(_command, args) {
const rendered = args.join(" ");
if (rendered.includes("ps --format json")) {
return { stdout: '{"State":"running"}\n', stderr: "" };
}
if (rendered.includes("ps -q")) {
return { stdout: "container-123\n", stderr: "" };
}
if (rendered.includes("inspect --format")) {
return { stdout: "172.18.0.10\n", stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return {
ok:
input === "http://172.18.0.10:8008/_matrix/client/versions" &&
fetchCalls.filter((url) => url === input).length > 1,
};
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
);
expect(result.baseUrl).toBe("http://172.18.0.10:8008/");
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
]);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,301 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort,
waitForDockerServiceHealth,
waitForHealth,
type FetchLike,
type RunCommand,
} from "../docker-runtime.js";
const MATRIX_QA_DEFAULT_IMAGE = "ghcr.io/matrix-construct/tuwunel:v1.5.1";
const MATRIX_QA_DEFAULT_SERVER_NAME = "matrix-qa.test";
const MATRIX_QA_DEFAULT_PORT = 28008;
const MATRIX_QA_INTERNAL_PORT = 8008;
const MATRIX_QA_SERVICE = "matrix-qa-homeserver";
type MatrixQaHarnessManifest = {
image: string;
serverName: string;
homeserverPort: number;
composeFile: string;
dataDir: string;
};
export type MatrixQaHarnessFiles = {
outputDir: string;
composeFile: string;
manifestPath: string;
image: string;
serverName: string;
homeserverPort: number;
registrationToken: string;
};
export type MatrixQaHarness = MatrixQaHarnessFiles & {
baseUrl: string;
restartService(): Promise<void>;
stopCommand: string;
stop(): Promise<void>;
};
function buildVersionsUrl(baseUrl: string) {
return `${baseUrl}_matrix/client/versions`;
}
async function isMatrixVersionsReachable(baseUrl: string, fetchImpl: FetchLike) {
return await fetchImpl(buildVersionsUrl(baseUrl))
.then((response) => response.ok)
.catch(() => false);
}
async function waitForReachableMatrixBaseUrl(params: {
composeFile: string;
containerBaseUrl: string | null;
fetchImpl: FetchLike;
hostBaseUrl: string;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
}) {
const timeoutMs = params.timeoutMs ?? 60_000;
const pollMs = params.pollMs ?? 1_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isMatrixVersionsReachable(params.hostBaseUrl, params.fetchImpl)) {
return params.hostBaseUrl;
}
if (
params.containerBaseUrl &&
(await isMatrixVersionsReachable(params.containerBaseUrl, params.fetchImpl))
) {
return params.containerBaseUrl;
}
await params.sleepImpl(pollMs);
}
const candidateLabel = params.containerBaseUrl
? `${params.hostBaseUrl} or ${params.containerBaseUrl}`
: params.hostBaseUrl;
throw new Error(
[
`Matrix homeserver did not become healthy within ${Math.round(timeoutMs / 1000)}s.`,
`Last checked: ${candidateLabel}`,
`Hint: check container logs with \`docker compose -f ${params.composeFile} logs ${MATRIX_QA_SERVICE}\`.`,
].join("\n"),
);
}
function resolveMatrixQaHarnessImage(image?: string) {
return (
image?.trim() || process.env.OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE?.trim() || MATRIX_QA_DEFAULT_IMAGE
);
}
function renderMatrixQaCompose(params: {
homeserverPort: number;
image: string;
registrationToken: string;
serverName: string;
}) {
return `services:
${MATRIX_QA_SERVICE}:
image: ${params.image}
ports:
- "127.0.0.1:${params.homeserverPort}:${MATRIX_QA_INTERNAL_PORT}"
environment:
TUWUNEL_ADDRESS: "0.0.0.0"
TUWUNEL_ALLOW_ENCRYPTION: "true"
TUWUNEL_ALLOW_FEDERATION: "false"
TUWUNEL_ALLOW_REGISTRATION: "true"
TUWUNEL_DATABASE_PATH: "/var/lib/tuwunel"
TUWUNEL_PORT: "${MATRIX_QA_INTERNAL_PORT}"
TUWUNEL_REGISTRATION_TOKEN: "${params.registrationToken}"
TUWUNEL_SERVER_NAME: "${params.serverName}"
volumes:
- ./data:/var/lib/tuwunel
`;
}
export async function writeMatrixQaHarnessFiles(params: {
outputDir: string;
image?: string;
homeserverPort: number;
registrationToken?: string;
serverName?: string;
}): Promise<MatrixQaHarnessFiles> {
const image = resolveMatrixQaHarnessImage(params.image);
const registrationToken = params.registrationToken?.trim() || `matrix-qa-${randomUUID()}`;
const serverName = params.serverName?.trim() || MATRIX_QA_DEFAULT_SERVER_NAME;
const composeFile = path.join(params.outputDir, "docker-compose.matrix-qa.yml");
const dataDir = path.join(params.outputDir, "data");
const manifestPath = path.join(params.outputDir, "matrix-qa-harness.json");
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(
composeFile,
`${renderMatrixQaCompose({
homeserverPort: params.homeserverPort,
image,
registrationToken,
serverName,
})}\n`,
{ encoding: "utf8", mode: 0o600 },
);
const manifest: MatrixQaHarnessManifest = {
image,
serverName,
homeserverPort: params.homeserverPort,
composeFile,
dataDir,
};
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return {
outputDir: params.outputDir,
composeFile,
manifestPath,
image,
serverName,
homeserverPort: params.homeserverPort,
registrationToken,
};
}
export async function startMatrixQaHarness(
params: {
outputDir: string;
repoRoot?: string;
image?: string;
homeserverPort?: number;
serverName?: string;
},
deps?: {
fetchImpl?: FetchLike;
runCommand?: RunCommand;
sleepImpl?: (ms: number) => Promise<unknown>;
resolveHostPortImpl?: typeof resolveHostPort;
},
): Promise<MatrixQaHarness> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const resolveHostPortImpl = deps?.resolveHostPortImpl ?? resolveHostPort;
const runCommand = deps?.runCommand ?? execCommand;
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
const sleepImpl = deps?.sleepImpl ?? sleep;
const homeserverPort = await resolveHostPortImpl(
params.homeserverPort ?? MATRIX_QA_DEFAULT_PORT,
params.homeserverPort != null,
);
const files = await writeMatrixQaHarnessFiles({
outputDir: path.resolve(params.outputDir),
image: params.image,
homeserverPort,
serverName: params.serverName,
});
try {
await runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
);
} catch {
// First run or already stopped.
}
await runCommand("docker", ["compose", "-f", files.composeFile, "up", "-d"], repoRoot);
await sleepImpl(1_000);
await waitForDockerServiceHealth(
MATRIX_QA_SERVICE,
files.composeFile,
repoRoot,
runCommand,
sleepImpl,
);
const hostBaseUrl = `http://127.0.0.1:${homeserverPort}/`;
let baseUrl = hostBaseUrl;
const hostReachable = await isMatrixVersionsReachable(hostBaseUrl, fetchImpl);
if (!hostReachable) {
const containerBaseUrl = await resolveComposeServiceUrl(
MATRIX_QA_SERVICE,
MATRIX_QA_INTERNAL_PORT,
files.composeFile,
repoRoot,
runCommand,
);
baseUrl = await waitForReachableMatrixBaseUrl({
composeFile: files.composeFile,
containerBaseUrl,
fetchImpl,
hostBaseUrl,
sleepImpl,
});
}
await waitForHealth(buildVersionsUrl(baseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
sleepImpl,
});
const waitForReady = async () => {
await sleepImpl(1_000);
await waitForDockerServiceHealth(
MATRIX_QA_SERVICE,
files.composeFile,
repoRoot,
runCommand,
sleepImpl,
);
await waitForHealth(buildVersionsUrl(baseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
sleepImpl,
});
};
return {
...files,
baseUrl,
async restartService() {
await runCommand(
"docker",
["compose", "-f", files.composeFile, "restart", MATRIX_QA_SERVICE],
repoRoot,
);
await waitForReady();
},
stopCommand: `docker compose -f ${files.composeFile} down --remove-orphans`,
async stop() {
await runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
);
},
};
}
export const __testing = {
MATRIX_QA_DEFAULT_IMAGE,
MATRIX_QA_DEFAULT_PORT,
MATRIX_QA_DEFAULT_SERVER_NAME,
MATRIX_QA_SERVICE,
buildVersionsUrl,
isMatrixVersionsReachable,
renderMatrixQaCompose,
resolveMatrixQaHarnessImage,
waitForReachableMatrixBaseUrl,
};

View file

@ -0,0 +1,55 @@
export type MatrixQaFetchLike = typeof fetch;
type MatrixQaRequestResult<T> = {
status: number;
body: T;
};
export async function requestMatrixJson<T>(params: {
accessToken?: string;
baseUrl: string;
body?: unknown;
endpoint: string;
fetchImpl: MatrixQaFetchLike;
method: "GET" | "POST" | "PUT";
okStatuses?: number[];
query?: Record<string, string | number | undefined>;
timeoutMs?: number;
}): Promise<MatrixQaRequestResult<T>> {
const url = new URL(params.endpoint, params.baseUrl);
for (const [key, value] of Object.entries(params.query ?? {})) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
const response = await params.fetchImpl(url, {
method: params.method,
headers: {
accept: "application/json",
...(params.body !== undefined ? { "content-type": "application/json" } : {}),
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
},
...(params.body !== undefined ? { body: JSON.stringify(params.body) } : {}),
signal: AbortSignal.timeout(params.timeoutMs ?? 20_000),
});
let body: unknown = {};
try {
body = (await response.json()) as unknown;
} catch {
body = {};
}
const okStatuses = params.okStatuses ?? [200];
if (!okStatuses.includes(response.status)) {
const details =
typeof body === "object" &&
body !== null &&
typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `${params.method} ${params.endpoint} failed with status ${response.status}`;
throw new Error(details);
}
return {
status: response.status,
body: body as T,
};
}

View file

@ -0,0 +1,279 @@
import { describe, expect, it } from "vitest";
import type { MatrixQaObservedEvent } from "./events.js";
import {
createMatrixQaRoomObserver,
primeMatrixQaRoom,
waitForOptionalMatrixQaRoomEvent,
} from "./sync.js";
describe("matrix sync helpers", () => {
it("primes the Matrix sync cursor without recording observed events", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(JSON.stringify({ next_batch: "primed-sync-cursor" }), {
status: 200,
headers: { "content-type": "application/json" },
});
await expect(
primeMatrixQaRoom({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
}),
).resolves.toBe("primed-sync-cursor");
});
it("returns a typed no-match result while preserving the latest sync token", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$driver",
sender: "@driver:matrix-qa.test",
type: "m.room.message",
content: { body: "hello", msgtype: "m.text" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const observedEvents: MatrixQaObservedEvent[] = [];
const result = await waitForOptionalMatrixQaRoomEvent({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
predicate: (event) => event.sender === "@sut:matrix-qa.test",
roomId: "!room:matrix-qa.test",
since: "start-batch",
timeoutMs: 1,
});
expect(result).toEqual({
matched: false,
since: "next-batch-2",
});
expect(observedEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "message",
body: "hello",
eventId: "$driver",
roomId: "!room:matrix-qa.test",
sender: "@driver:matrix-qa.test",
type: "m.room.message",
}),
]),
);
});
it("keeps recording later same-batch events after the first match", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$sut",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "target", msgtype: "m.text" },
},
{
event_id: "$driver",
sender: "@driver:matrix-qa.test",
type: "m.room.message",
content: { body: "trailing event", msgtype: "m.text" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const observedEvents: MatrixQaObservedEvent[] = [];
const result = await waitForOptionalMatrixQaRoomEvent({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
predicate: (event) => event.eventId === "$sut",
roomId: "!room:matrix-qa.test",
since: "start-batch",
timeoutMs: 1,
});
expect(result).toEqual({
event: expect.objectContaining({
eventId: "$sut",
}),
matched: true,
since: "next-batch-2",
});
expect(observedEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "message",
body: "target",
eventId: "$sut",
}),
expect.objectContaining({
kind: "message",
body: "trailing event",
eventId: "$driver",
}),
]),
);
});
it("lets a second wait reuse later same-batch events without another /sync", async () => {
let calls = 0;
const fetchImpl: typeof fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$preview",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "preview", msgtype: "m.notice" },
},
{
event_id: "$final",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "final",
msgtype: "m.text",
"m.relates_to": {
rel_type: "m.replace",
event_id: "$preview",
"m.new_content": { body: "final", msgtype: "m.text" },
},
},
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const observedEvents: MatrixQaObservedEvent[] = [];
const observer = createMatrixQaRoomObserver({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
since: "start-batch",
});
const preview = await observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$preview",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
});
const finalized = await observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$final",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
});
expect(preview.event.eventId).toBe("$preview");
expect(finalized.event.eventId).toBe("$final");
expect(calls).toBe(1);
});
it("shares one in-flight /sync poll across concurrent waits", async () => {
let calls = 0;
const fetchImpl: typeof fetch = async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$reply",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "reply", msgtype: "m.text" },
},
{
event_id: "$notice",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "notice", msgtype: "m.notice" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const observer = createMatrixQaRoomObserver({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents: [],
since: "start-batch",
});
const [reply, notice] = await Promise.all([
observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$reply",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
}),
observer.waitForOptionalRoomEvent({
predicate: (event) => event.eventId === "$notice",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
}),
]);
expect(reply.event.eventId).toBe("$reply");
expect(notice).toMatchObject({
event: expect.objectContaining({
eventId: "$notice",
}),
matched: true,
});
expect(calls).toBe(1);
});
});

View file

@ -0,0 +1,238 @@
import type { MatrixQaObservedEvent } from "./events.js";
import { normalizeMatrixQaObservedEvent, type MatrixQaRoomEvent } from "./events.js";
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
type MatrixQaSyncResponse = {
next_batch?: string;
rooms?: {
join?: Record<
string,
{
timeline?: {
events?: MatrixQaRoomEvent[];
};
}
>;
};
};
export type MatrixQaRoomEventWaitResult =
| {
event: MatrixQaObservedEvent;
matched: true;
since?: string;
}
| {
matched: false;
since?: string;
};
type MatrixQaSyncParams = {
accessToken?: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
};
export type MatrixQaRoomObserver = {
prime(): Promise<string | undefined>;
waitForOptionalRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<MatrixQaRoomEventWaitResult>;
waitForRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<{
event: MatrixQaObservedEvent;
since?: string;
}>;
};
type MatrixQaRoomObserverState = {
cursorIndex: number;
events: MatrixQaObservedEvent[];
pollPromise?: Promise<void>;
since?: string;
};
export async function primeMatrixQaRoom(params: MatrixQaSyncParams) {
const fetchImpl = params.fetchImpl ?? fetch;
const response = await requestMatrixJson<MatrixQaSyncResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { timeout: 0 },
});
return response.body.next_batch?.trim() || undefined;
}
async function pollMatrixQaRoomObserver(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
roomObserver: MatrixQaRoomObserverState;
timeoutMs: number;
},
) {
const fetchImpl = params.fetchImpl ?? fetch;
if (params.roomObserver.pollPromise) {
await params.roomObserver.pollPromise;
return;
}
params.roomObserver.pollPromise = (async () => {
const response = await requestMatrixJson<MatrixQaSyncResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: {
...(params.roomObserver.since ? { since: params.roomObserver.since } : {}),
timeout: Math.min(10_000, params.timeoutMs),
},
timeoutMs: Math.min(15_000, params.timeoutMs + 5_000),
});
params.roomObserver.since = response.body.next_batch?.trim() || params.roomObserver.since;
for (const [roomId, joinedRoom] of Object.entries(response.body.rooms?.join ?? {})) {
for (const event of joinedRoom.timeline?.events ?? []) {
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
if (!normalized) {
continue;
}
params.observedEvents.push(normalized);
params.roomObserver.events.push(normalized);
}
}
})();
try {
await params.roomObserver.pollPromise;
} finally {
params.roomObserver.pollPromise = undefined;
}
}
function findObservedEventMatch(params: {
cursorIndex: number;
events: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
}) {
for (let index = params.cursorIndex; index < params.events.length; index += 1) {
const event = params.events[index];
if (event?.roomId !== params.roomId) {
continue;
}
if (params.predicate(event)) {
return {
event,
nextCursorIndex: index + 1,
};
}
}
return undefined;
}
export function createMatrixQaRoomObserver(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
since?: string;
},
): MatrixQaRoomObserver {
const roomObserver: MatrixQaRoomObserverState = {
cursorIndex: 0,
events: [],
since: params.since,
};
return {
async prime() {
if (roomObserver.since) {
return roomObserver.since;
}
roomObserver.since = await primeMatrixQaRoom(params);
return roomObserver.since;
},
async waitForOptionalRoomEvent(waitParams) {
const startSince = await this.prime();
const startedAt = Date.now();
let cursorIndex = roomObserver.cursorIndex;
while (Date.now() - startedAt < waitParams.timeoutMs) {
const matched = findObservedEventMatch({
cursorIndex,
events: roomObserver.events,
predicate: waitParams.predicate,
roomId: waitParams.roomId,
});
if (matched) {
roomObserver.cursorIndex = Math.max(roomObserver.cursorIndex, matched.nextCursorIndex);
return {
event: matched.event,
matched: true,
since: roomObserver.since ?? startSince,
};
}
cursorIndex = roomObserver.events.length;
const remainingMs = Math.max(1_000, waitParams.timeoutMs - (Date.now() - startedAt));
await pollMatrixQaRoomObserver({
...params,
observedEvents: params.observedEvents,
roomObserver,
timeoutMs: remainingMs,
});
}
roomObserver.cursorIndex = Math.max(roomObserver.cursorIndex, cursorIndex);
return {
matched: false,
since: roomObserver.since ?? startSince,
};
},
async waitForRoomEvent(waitParams) {
const result = await this.waitForOptionalRoomEvent(waitParams);
if (result.matched) {
return {
event: result.event,
since: result.since,
};
}
throw new Error(`timed out after ${waitParams.timeoutMs}ms waiting for Matrix room event`);
},
};
}
export async function waitForOptionalMatrixQaRoomEvent(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
},
): Promise<MatrixQaRoomEventWaitResult> {
return await createMatrixQaRoomObserver(params).waitForOptionalRoomEvent({
predicate: params.predicate,
roomId: params.roomId,
timeoutMs: params.timeoutMs,
});
}
export async function waitForMatrixQaRoomEvent(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
},
) {
const result = await waitForOptionalMatrixQaRoomEvent(params);
if (result.matched) {
return { event: result.event, since: result.since };
}
throw new Error(`timed out after ${params.timeoutMs}ms waiting for Matrix room event`);
}

View file

@ -0,0 +1,106 @@
export type MatrixQaParticipantRole = "driver" | "observer" | "sut";
export type MatrixQaRoomKind = "dm" | "group";
export type MatrixQaTopologyRoomSpec = {
encrypted?: boolean;
key: string;
kind: MatrixQaRoomKind;
members: MatrixQaParticipantRole[];
name: string;
requireMention?: boolean;
};
export type MatrixQaTopologySpec = {
defaultRoomKey: string;
rooms: MatrixQaTopologyRoomSpec[];
};
export type MatrixQaProvisionedRoom = {
encrypted?: boolean;
key: string;
kind: MatrixQaRoomKind;
memberRoles: MatrixQaParticipantRole[];
memberUserIds: string[];
name: string;
requireMention: boolean;
roomId: string;
};
export type MatrixQaProvisionedTopology = {
defaultRoomId: string;
defaultRoomKey: string;
rooms: MatrixQaProvisionedRoom[];
};
function matrixQaRoomSpecsEqual(left: MatrixQaTopologyRoomSpec, right: MatrixQaTopologyRoomSpec) {
return (
left.key === right.key &&
(left.encrypted === true) === (right.encrypted === true) &&
left.kind === right.kind &&
left.name === right.name &&
left.requireMention === right.requireMention &&
left.members.length === right.members.length &&
left.members.every((member, index) => member === right.members[index])
);
}
export function buildDefaultMatrixQaTopologySpec(params: {
defaultRoomName: string;
}): MatrixQaTopologySpec {
return {
defaultRoomKey: "main",
rooms: [
{
encrypted: false,
key: "main",
kind: "group",
members: ["driver", "observer", "sut"],
name: params.defaultRoomName,
requireMention: true,
},
],
};
}
export function findMatrixQaProvisionedRoom(
topology: MatrixQaProvisionedTopology,
key: string,
): MatrixQaProvisionedRoom {
const room = topology.rooms.find((entry) => entry.key === key);
if (!room) {
throw new Error(`Matrix QA topology is missing room "${key}"`);
}
return room;
}
export function mergeMatrixQaTopologySpecs(specs: MatrixQaTopologySpec[]): MatrixQaTopologySpec {
const first = specs[0];
if (!first) {
throw new Error("Matrix QA topology merge requires at least one spec");
}
const roomByKey = new Map<string, MatrixQaTopologyRoomSpec>();
for (const spec of specs) {
if (spec.defaultRoomKey !== first.defaultRoomKey) {
throw new Error(
`Matrix QA topology default room mismatch: ${spec.defaultRoomKey} !== ${first.defaultRoomKey}`,
);
}
for (const room of spec.rooms) {
const existing = roomByKey.get(room.key);
if (!existing) {
roomByKey.set(room.key, room);
continue;
}
if (!matrixQaRoomSpecsEqual(existing, room)) {
throw new Error(`Matrix QA topology room "${room.key}" has conflicting definitions`);
}
}
}
return {
defaultRoomKey: first.defaultRoomKey,
rooms: [...roomByKey.values()],
};
}