mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 04:10:44 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
4
openclaw/extensions/qa-matrix/src/cli-options.ts
Normal file
4
openclaw/extensions/qa-matrix/src/cli-options.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function collectString(value: string, previous: string[]) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [...previous, trimmed] : previous;
|
||||
}
|
||||
16
openclaw/extensions/qa-matrix/src/cli-paths.ts
Normal file
16
openclaw/extensions/qa-matrix/src/cli-paths.ts
Normal 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;
|
||||
}
|
||||
135
openclaw/extensions/qa-matrix/src/cli.runtime.test.ts
Normal file
135
openclaw/extensions/qa-matrix/src/cli.runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
91
openclaw/extensions/qa-matrix/src/cli.runtime.ts
Normal file
91
openclaw/extensions/qa-matrix/src/cli.runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
99
openclaw/extensions/qa-matrix/src/cli.test.ts
Normal file
99
openclaw/extensions/qa-matrix/src/cli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
openclaw/extensions/qa-matrix/src/cli.ts
Normal file
68
openclaw/extensions/qa-matrix/src/cli.ts
Normal 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);
|
||||
}
|
||||
274
openclaw/extensions/qa-matrix/src/docker-runtime.ts
Normal file
274
openclaw/extensions/qa-matrix/src/docker-runtime.ts
Normal 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,
|
||||
};
|
||||
100
openclaw/extensions/qa-matrix/src/report.ts
Normal file
100
openclaw/extensions/qa-matrix/src/report.ts
Normal 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");
|
||||
}
|
||||
19
openclaw/extensions/qa-matrix/src/run-config.test.ts
Normal file
19
openclaw/extensions/qa-matrix/src/run-config.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
16
openclaw/extensions/qa-matrix/src/run-config.ts
Normal file
16
openclaw/extensions/qa-matrix/src/run-config.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
947
openclaw/extensions/qa-matrix/src/runners/contract/runtime.ts
Normal file
947
openclaw/extensions/qa-matrix/src/runners/contract/runtime.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
2736
openclaw/extensions/qa-matrix/src/runners/contract/scenarios.test.ts
Normal file
2736
openclaw/extensions/qa-matrix/src/runners/contract/scenarios.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
};
|
||||
9
openclaw/extensions/qa-matrix/src/runtime-api.test.ts
Normal file
9
openclaw/extensions/qa-matrix/src/runtime-api.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
132
openclaw/extensions/qa-matrix/src/shared/live-transport-cli.ts
Normal file
132
openclaw/extensions/qa-matrix/src/shared/live-transport-cli.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
156
openclaw/extensions/qa-matrix/src/substrate/artifacts.test.ts
Normal file
156
openclaw/extensions/qa-matrix/src/substrate/artifacts.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
31
openclaw/extensions/qa-matrix/src/substrate/artifacts.ts
Normal file
31
openclaw/extensions/qa-matrix/src/substrate/artifacts.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
603
openclaw/extensions/qa-matrix/src/substrate/client.test.ts
Normal file
603
openclaw/extensions/qa-matrix/src/substrate/client.test.ts
Normal 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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
914
openclaw/extensions/qa-matrix/src/substrate/client.ts
Normal file
914
openclaw/extensions/qa-matrix/src/substrate/client.ts
Normal 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 "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
case "'":
|
||||
return "'";
|
||||
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,
|
||||
};
|
||||
298
openclaw/extensions/qa-matrix/src/substrate/config.test.ts
Normal file
298
openclaw/extensions/qa-matrix/src/substrate/config.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
452
openclaw/extensions/qa-matrix/src/substrate/config.ts
Normal file
452
openclaw/extensions/qa-matrix/src/substrate/config.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
408
openclaw/extensions/qa-matrix/src/substrate/e2ee-client.ts
Normal file
408
openclaw/extensions/qa-matrix/src/substrate/e2ee-client.ts
Normal 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,
|
||||
};
|
||||
221
openclaw/extensions/qa-matrix/src/substrate/events.test.ts
Normal file
221
openclaw/extensions/qa-matrix/src/substrate/events.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
226
openclaw/extensions/qa-matrix/src/substrate/events.ts
Normal file
226
openclaw/extensions/qa-matrix/src/substrate/events.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
127
openclaw/extensions/qa-matrix/src/substrate/fault-proxy.test.ts
Normal file
127
openclaw/extensions/qa-matrix/src/substrate/fault-proxy.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
226
openclaw/extensions/qa-matrix/src/substrate/fault-proxy.ts
Normal file
226
openclaw/extensions/qa-matrix/src/substrate/fault-proxy.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
301
openclaw/extensions/qa-matrix/src/substrate/harness.runtime.ts
Normal file
301
openclaw/extensions/qa-matrix/src/substrate/harness.runtime.ts
Normal 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,
|
||||
};
|
||||
55
openclaw/extensions/qa-matrix/src/substrate/request.ts
Normal file
55
openclaw/extensions/qa-matrix/src/substrate/request.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
279
openclaw/extensions/qa-matrix/src/substrate/sync.test.ts
Normal file
279
openclaw/extensions/qa-matrix/src/substrate/sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
openclaw/extensions/qa-matrix/src/substrate/sync.ts
Normal file
238
openclaw/extensions/qa-matrix/src/substrate/sync.ts
Normal 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`);
|
||||
}
|
||||
106
openclaw/extensions/qa-matrix/src/substrate/topology.ts
Normal file
106
openclaw/extensions/qa-matrix/src/substrate/topology.ts
Normal 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()],
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue