Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
48
extensions/qa-matrix/src/cli-paths.ts
Normal file
48
extensions/qa-matrix/src/cli-paths.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Qa Matrix plugin module implements cli paths behavior.
|
||||
import path from "node:path";
|
||||
import { assertNoSymlinkParents, pathScope } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function assertRepoRelativePath(repoRoot: string, targetPath: string, label: string) {
|
||||
const relative = path.relative(repoRoot, targetPath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} must stay within the repo root.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureRepoBoundDirectory(repoRoot: string, targetDir: string, label: string) {
|
||||
const repoRootResolved = path.resolve(repoRoot);
|
||||
const targetResolved = path.resolve(targetDir);
|
||||
assertRepoRelativePath(repoRootResolved, targetResolved, label);
|
||||
try {
|
||||
await assertNoSymlinkParents({
|
||||
rootDir: repoRootResolved,
|
||||
targetPath: targetResolved,
|
||||
messagePrefix: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("symlink")) {
|
||||
throw new Error(`${label} must not traverse symlinks.`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await pathScope(repoRootResolved, { label }).ensureDir(targetResolved);
|
||||
if (!result.ok) {
|
||||
throw new Error(`${label} must stay within the repo root.`);
|
||||
}
|
||||
return result.path;
|
||||
}
|
||||
181
extensions/qa-matrix/src/cli.runtime.test.ts
Normal file
181
extensions/qa-matrix/src/cli.runtime.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Qa Matrix tests cover cli plugin behavior.
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink } 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", async () => {
|
||||
const actual = await vi.importActual<typeof import("undici")>("undici");
|
||||
return {
|
||||
...actual,
|
||||
getGlobalDispatcher: () => ({
|
||||
close: closeGlobalDispatcher,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { runQaMatrixCommand } from "./cli.runtime.js";
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
let error: unknown;
|
||||
try {
|
||||
await readFile(targetPath, "utf8");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
}
|
||||
|
||||
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",
|
||||
routeStateManifestPath: "/tmp/matrix-route-state.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({
|
||||
repoRoot,
|
||||
outputDir: path.join(repoRoot, ".artifacts/qa-e2e/matrix"),
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: undefined,
|
||||
alternateModel: undefined,
|
||||
fastMode: undefined,
|
||||
failFast: undefined,
|
||||
profile: undefined,
|
||||
scenarioIds: undefined,
|
||||
sutAccountId: undefined,
|
||||
credentialSource: "env",
|
||||
credentialRole: undefined,
|
||||
});
|
||||
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",
|
||||
routeStateManifestPath: "/tmp/matrix-route-state.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 expectPathMissing(outputPath);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects output dirs that traverse repo-local symlinks",
|
||||
async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
|
||||
const externalOutputRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-external-"));
|
||||
tmpDirs.push(repoRoot, externalOutputRoot);
|
||||
await mkdir(path.join(repoRoot, ".artifacts"), { recursive: true });
|
||||
await symlink(externalOutputRoot, path.join(repoRoot, ".artifacts", "qa-e2e"));
|
||||
|
||||
await expect(
|
||||
runQaMatrixCommand({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/matrix",
|
||||
providerMode: "mock-openai",
|
||||
credentialSource: "env",
|
||||
}),
|
||||
).rejects.toThrow("Matrix QA output dir must not traverse symlinks.");
|
||||
|
||||
expect(runMatrixQaLive).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
114
extensions/qa-matrix/src/cli.runtime.ts
Normal file
114
extensions/qa-matrix/src/cli.runtime.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// Qa Matrix plugin module implements cli behavior.
|
||||
import {
|
||||
printLiveTransportQaArtifacts,
|
||||
startLiveTransportQaOutputTee,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
import { ensureRepoBoundDirectory } from "./cli-paths.js";
|
||||
import { runMatrixQaLive } from "./runners/contract/runtime.js";
|
||||
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
|
||||
import { resolveLiveTransportQaRunOptions } 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 outputDir = await ensureRepoBoundDirectory(
|
||||
runOptions.repoRoot,
|
||||
runOptions.outputDir,
|
||||
"Matrix QA output dir",
|
||||
);
|
||||
const checkedRunOptions = { ...runOptions, outputDir };
|
||||
const outputTee = await createMatrixQaCommandOutputTee(checkedRunOptions.outputDir);
|
||||
let primaryError: unknown;
|
||||
let outputTeeError: unknown;
|
||||
try {
|
||||
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
|
||||
const result = await runMatrixQaLive(checkedRunOptions);
|
||||
printLiveTransportQaArtifacts("Matrix QA", {
|
||||
report: result.reportPath,
|
||||
"route/state manifest": result.routeStateManifestPath,
|
||||
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 toLintErrorObject(primaryError, "Non-Error thrown");
|
||||
}
|
||||
if (outputTeeError) {
|
||||
throw toLintErrorObject(outputTeeError, "Non-Error thrown");
|
||||
}
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
102
extensions/qa-matrix/src/cli.test.ts
Normal file
102
extensions/qa-matrix/src/cli.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Qa Matrix tests cover cli plugin behavior.
|
||||
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) ?? [];
|
||||
|
||||
for (const optionName of [
|
||||
"--repo-root",
|
||||
"--output-dir",
|
||||
"--provider-mode",
|
||||
"--model",
|
||||
"--alt-model",
|
||||
"--scenario",
|
||||
"--fast",
|
||||
"--profile",
|
||||
"--fail-fast",
|
||||
"--sut-account",
|
||||
]) {
|
||||
expect(optionNames).toContain(optionName);
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
72
extensions/qa-matrix/src/cli.ts
Normal file
72
extensions/qa-matrix/src/cli.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Qa Matrix plugin module implements cli behavior.
|
||||
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",
|
||||
profileHelp:
|
||||
"Matrix QA profile: all, fast, transport, media, e2ee-smoke, e2ee-deep, or e2ee-cli (default: all)",
|
||||
failFastHelp: "Stop after the first failed Matrix check or scenario",
|
||||
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);
|
||||
}
|
||||
21
extensions/qa-matrix/src/docker-runtime.ts
Normal file
21
extensions/qa-matrix/src/docker-runtime.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Qa Matrix plugin module implements docker runtime behavior.
|
||||
import {
|
||||
createQaDockerRuntime,
|
||||
type QaDockerFetchLike as FetchLike,
|
||||
type QaDockerRunCommand as RunCommand,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
|
||||
export type { FetchLike, RunCommand };
|
||||
|
||||
const dockerRuntime = createQaDockerRuntime({
|
||||
auditContext: "qa-matrix-docker-health-check",
|
||||
});
|
||||
|
||||
export const {
|
||||
execCommand,
|
||||
fetchHealthUrl,
|
||||
resolveComposeServiceUrl,
|
||||
resolveHostPort,
|
||||
waitForDockerServiceHealth,
|
||||
waitForHealth,
|
||||
} = dockerRuntime;
|
||||
20
extensions/qa-matrix/src/run-config.test.ts
Normal file
20
extensions/qa-matrix/src/run-config.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Qa Matrix tests cover run config plugin behavior.
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
17
extensions/qa-matrix/src/run-config.ts
Normal file
17
extensions/qa-matrix/src/run-config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Qa Matrix helper module supports run config behavior.
|
||||
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,54 @@
|
||||
// Qa Matrix tests cover model selection plugin behavior.
|
||||
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();
|
||||
});
|
||||
});
|
||||
34
extensions/qa-matrix/src/runners/contract/model-selection.ts
Normal file
34
extensions/qa-matrix/src/runners/contract/model-selection.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Qa Matrix plugin module implements model selection behavior.
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
806
extensions/qa-matrix/src/runners/contract/runtime.test.ts
Normal file
806
extensions/qa-matrix/src/runners/contract/runtime.test.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
// Qa Matrix tests cover runtime plugin behavior.
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { testing as liveTesting } from "./runtime.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
type MatrixQaSummaryInput = Parameters<typeof liveTesting.buildMatrixQaSummary>[0];
|
||||
type MatrixQaSummaryInputOverrides = Partial<Omit<MatrixQaSummaryInput, "timings">> & {
|
||||
timings?: Partial<MatrixQaSummaryInput["timings"]>;
|
||||
};
|
||||
|
||||
function buildMatrixQaSummaryInput(
|
||||
overrides: MatrixQaSummaryInputOverrides = {},
|
||||
): MatrixQaSummaryInput {
|
||||
const timings: MatrixQaSummaryInput["timings"] = {
|
||||
artifactWriteMs: 5,
|
||||
canaryMs: 40,
|
||||
harnessBootMs: 100,
|
||||
initialGatewayBootMs: 200,
|
||||
provisioningMs: 300,
|
||||
scenarioGatewayBootMs: 50,
|
||||
scenarioRestartGatewayMs: 60,
|
||||
scenarioTransportInterruptMs: 70,
|
||||
scenarios: [],
|
||||
totalMs: 825,
|
||||
...overrides.timings,
|
||||
};
|
||||
|
||||
return {
|
||||
artifactPaths: {
|
||||
observedEvents: "/tmp/observed.json",
|
||||
report: "/tmp/report.md",
|
||||
routeStateManifest: "/tmp/route-state.json",
|
||||
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: [],
|
||||
},
|
||||
differentialProbe: {
|
||||
profile: "matrix-qa-v1",
|
||||
steps: [],
|
||||
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
|
||||
},
|
||||
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: [],
|
||||
startedAt: "2026-04-10T10:00:00.000Z",
|
||||
sutAccountId: "sut",
|
||||
userIds: {
|
||||
driver: "@driver:matrix-qa.test",
|
||||
observer: "@observer:matrix-qa.test",
|
||||
sut: "@sut:matrix-qa.test",
|
||||
},
|
||||
...overrides,
|
||||
timings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix live qa runtime", () => {
|
||||
it("preserves a failed differential probe check without a probe payload", () => {
|
||||
const summary = liveTesting.buildMatrixQaSummary(
|
||||
buildMatrixQaSummaryInput({
|
||||
checks: [
|
||||
{ name: "Matrix harness ready", status: "pass" },
|
||||
{
|
||||
details: "missing-state response did not return M_NOT_FOUND",
|
||||
name: "Matrix differential probe",
|
||||
status: "fail",
|
||||
},
|
||||
],
|
||||
differentialProbe: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.differentialProbe).toBeUndefined();
|
||||
expect(summary.counts).toEqual({ failed: 1, passed: 1, total: 2 });
|
||||
});
|
||||
|
||||
it("uses unique default artifact directories", () => {
|
||||
const repoRoot = "/repo";
|
||||
const firstOutputDir = liveTesting.resolveMatrixQaOutputDir({ repoRoot });
|
||||
const secondOutputDir = liveTesting.resolveMatrixQaOutputDir({ repoRoot });
|
||||
|
||||
expect(path.dirname(firstOutputDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
|
||||
expect(path.basename(firstOutputDir)).toMatch(/^matrix-[a-z0-9]+-[a-f0-9]{8}$/u);
|
||||
expect(secondOutputDir).not.toBe(firstOutputDir);
|
||||
expect(liveTesting.resolveMatrixQaOutputDir({ outputDir: ".artifacts/custom", repoRoot })).toBe(
|
||||
".artifacts/custom",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints Matrix QA progress by default for non-interactive runs", () => {
|
||||
const previous = process.env.OPENCLAW_QA_MATRIX_PROGRESS;
|
||||
delete process.env.OPENCLAW_QA_MATRIX_PROGRESS;
|
||||
try {
|
||||
expect(liveTesting.shouldWriteMatrixQaProgress()).toBe(true);
|
||||
process.env.OPENCLAW_QA_MATRIX_PROGRESS = "0";
|
||||
expect(liveTesting.shouldWriteMatrixQaProgress()).toBe(false);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_QA_MATRIX_PROGRESS;
|
||||
} else {
|
||||
process.env.OPENCLAW_QA_MATRIX_PROGRESS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("summarizes relevant gateway stderr lines for Matrix QA failures", () => {
|
||||
const summary = liveTesting.summarizeMatrixQaGatewayStderrLog(
|
||||
[
|
||||
"normal gateway progress",
|
||||
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
|
||||
"[agent/embedded] embedded run failover decision: stage=prompt decision=surface_error reason=auth",
|
||||
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(summary).toContain("gateway stderr tail:");
|
||||
expect(summary).toContain("Authorization: Bearer");
|
||||
expect(summary).toContain("reason=auth");
|
||||
expect(summary).toContain("unexpected status 401 Unauthorized");
|
||||
expect(summary).not.toContain("normal gateway progress");
|
||||
expect(summary).not.toContain("abcdefghijklmnopqrstuvwxyz");
|
||||
});
|
||||
|
||||
it("skips empty gateway stderr summaries", () => {
|
||||
expect(liveTesting.summarizeMatrixQaGatewayStderrLog("\n\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes the Matrix QA hard timeout env", () => {
|
||||
const previous = process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS;
|
||||
try {
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "12345";
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(12345);
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "+012345";
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(12345);
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "nope";
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "1e3";
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "1.5";
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = String(Number.MAX_SAFE_INTEGER);
|
||||
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not start Matrix QA work after the hard run deadline expires", async () => {
|
||||
const task = vi.fn(async () => "started");
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_001);
|
||||
|
||||
await expect(
|
||||
liveTesting.withMatrixQaRunDeadline(
|
||||
{
|
||||
deadlineMs: 1_000,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
"Matrix scenario late",
|
||||
task,
|
||||
),
|
||||
).rejects.toThrow(/Matrix scenario late not started because Matrix QA run timed out/u);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the remaining Matrix QA run budget to the phase timeout", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
|
||||
expect(
|
||||
liveTesting.remainingMatrixQaRunMs(
|
||||
{
|
||||
deadlineMs: 1_250,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
"Matrix canary",
|
||||
),
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it("normalizes the Matrix QA canary timeout env", () => {
|
||||
const previous = process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
|
||||
try {
|
||||
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "90000";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "+090000";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "nope";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "0x1000";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a scenario provider override for the canary only when the whole run is pinned", () => {
|
||||
const blockStreamingScenario = liveTesting.MATRIX_QA_SCENARIOS.find(
|
||||
(scenario) => scenario.id === "matrix-room-block-streaming",
|
||||
);
|
||||
const threadScenario = liveTesting.MATRIX_QA_SCENARIOS.find(
|
||||
(scenario) => scenario.id === "matrix-thread-follow-up",
|
||||
);
|
||||
expect(blockStreamingScenario).toBeDefined();
|
||||
expect(threadScenario).toBeDefined();
|
||||
|
||||
const pinnedSchedule = liveTesting.scheduleMatrixQaScenariosInCatalogOrder([
|
||||
blockStreamingScenario!,
|
||||
]);
|
||||
expect(liveTesting.selectMatrixQaCanaryProviderMode(pinnedSchedule)).toBe("mock-openai");
|
||||
|
||||
const mixedSchedule = liveTesting.scheduleMatrixQaScenariosInCatalogOrder([
|
||||
threadScenario!,
|
||||
blockStreamingScenario!,
|
||||
]);
|
||||
expect(liveTesting.selectMatrixQaCanaryProviderMode(mixedSchedule)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit model pins when a scenario keeps the suite provider", () => {
|
||||
const defaultModels = {
|
||||
alternateModel: "mock-openai/custom-alt",
|
||||
primaryModel: "mock-openai/custom",
|
||||
providerMode: "mock-openai" as const,
|
||||
};
|
||||
|
||||
expect(
|
||||
liveTesting.resolveMatrixQaGatewayModels({
|
||||
defaultModels,
|
||||
providerMode: "mock-openai",
|
||||
}),
|
||||
).toEqual(defaultModels);
|
||||
});
|
||||
|
||||
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.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
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", () => {
|
||||
const summary = liveTesting.buildMatrixQaSummary({
|
||||
artifactPaths: {
|
||||
observedEvents: "/tmp/observed.json",
|
||||
report: "/tmp/report.md",
|
||||
routeStateManifest: "/tmp/route-state.json",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
differentialProbe: {
|
||||
profile: "matrix-qa-v1",
|
||||
steps: [],
|
||||
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
|
||||
},
|
||||
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",
|
||||
},
|
||||
});
|
||||
const config = summary.config;
|
||||
expect(config.default.replyToMode).toBe("off");
|
||||
expect(config.default.threadReplies).toBe("inbound");
|
||||
expect(config.scenarios).toHaveLength(1);
|
||||
expect(config.scenarios[0]?.id).toBe("matrix-room-thread-reply-override");
|
||||
expect(config.scenarios[0]?.config.threadReplies).toBe("always");
|
||||
});
|
||||
|
||||
it("preserves negative-scenario artifacts in the Matrix summary", () => {
|
||||
const summary = liveTesting.buildMatrixQaSummary(
|
||||
buildMatrixQaSummaryInput({
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
timings: {
|
||||
scenarios: [
|
||||
{
|
||||
durationMs: 80,
|
||||
gatewayBootMs: 0,
|
||||
gatewayRestartMs: 0,
|
||||
id: "matrix-mention-gating",
|
||||
title: "Matrix room message without mention does not trigger",
|
||||
transportInterruptMs: 0,
|
||||
},
|
||||
],
|
||||
totalMs: 905,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(summary.counts.total).toBe(2);
|
||||
expect(summary.counts.passed).toBe(2);
|
||||
expect(summary.counts.failed).toBe(0);
|
||||
expect(summary.scenarios[0]?.id).toBe("matrix-mention-gating");
|
||||
expect(summary.scenarios[0]?.artifacts?.actorUserId).toBe("@driver:matrix-qa.test");
|
||||
expect(summary.scenarios[0]?.artifacts?.expectedNoReplyWindowMs).toBe(8_000);
|
||||
expect(summary.scenarios[0]?.artifacts?.triggerBody).toBe(
|
||||
"reply with only this exact marker: MATRIX_QA_NOMENTION_TOKEN",
|
||||
);
|
||||
expect(summary.timings.totalMs).toBe(905);
|
||||
});
|
||||
|
||||
it("keeps failing Matrix scenario details and timings complete in summary + report output", () => {
|
||||
const summary = liveTesting.buildMatrixQaSummary(
|
||||
buildMatrixQaSummaryInput({
|
||||
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"),
|
||||
},
|
||||
],
|
||||
timings: {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.counts.total).toBe(2);
|
||||
expect(summary.counts.passed).toBe(1);
|
||||
expect(summary.counts.failed).toBe(1);
|
||||
expect(summary.scenarios[0]?.id).toBe("matrix-reaction-not-a-reply");
|
||||
expect(summary.scenarios[0]?.status).toBe("fail");
|
||||
expect(summary.scenarios[0]?.details).toContain("reaction event: $reaction");
|
||||
expect(summary.timings.scenarios[0]?.id).toBe("matrix-reaction-not-a-reply");
|
||||
expect(summary.timings.scenarios[0]?.durationMs).toBe(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("groups Matrix scenario execution by gateway config while preserving tail scenarios", () => {
|
||||
const scenarios = liveTesting.findMatrixQaScenarios([
|
||||
"matrix-thread-follow-up",
|
||||
"matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
"matrix-thread-isolation",
|
||||
"matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
"matrix-e2ee-wrong-account-recovery-key",
|
||||
]);
|
||||
|
||||
expect(
|
||||
liveTesting
|
||||
.scheduleMatrixQaScenariosInCatalogOrder(scenarios)
|
||||
.map(({ scenario }) => scenario.id),
|
||||
).toEqual([
|
||||
"matrix-thread-follow-up",
|
||||
"matrix-thread-isolation",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
"matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
"matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
"matrix-e2ee-wrong-account-recovery-key",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the scenario timeout for post-restart Matrix readiness", () => {
|
||||
expect(
|
||||
liveTesting.getMatrixQaScenarioRestartReadyTimeoutMs({
|
||||
timeoutMs: 180_000,
|
||||
}),
|
||||
).toBe(180_000);
|
||||
});
|
||||
|
||||
it("retries Matrix gateway config patches after a stale config hash", async () => {
|
||||
const patch = {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const gateway = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ hash: "hash-old" })
|
||||
.mockRejectedValueOnce(
|
||||
new Error("config changed since last load; re-run config.get and retry"),
|
||||
)
|
||||
.mockResolvedValueOnce({ hash: "hash-fresh" })
|
||||
.mockResolvedValueOnce(undefined),
|
||||
};
|
||||
|
||||
await liveTesting.patchMatrixQaGatewayConfig({
|
||||
gateway: gateway as never,
|
||||
patch,
|
||||
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
|
||||
restartDelayMs: 250,
|
||||
});
|
||||
|
||||
expect(gateway.call).toHaveBeenNthCalledWith(1, "config.get", {}, { timeoutMs: 60_000 });
|
||||
expect(gateway.call).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"config.patch",
|
||||
{
|
||||
baseHash: "hash-old",
|
||||
raw: JSON.stringify(patch, null, 2),
|
||||
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
|
||||
restartDelayMs: 250,
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(gateway.call).toHaveBeenNthCalledWith(3, "config.get", {}, { timeoutMs: 60_000 });
|
||||
expect(gateway.call).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"config.patch",
|
||||
{
|
||||
baseHash: "hash-fresh",
|
||||
raw: JSON.stringify(patch, null, 2),
|
||||
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
|
||||
restartDelayMs: 250,
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
it("caps Matrix readiness status RPCs and sleeps to the remaining timeout budget", 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: 1_000,
|
||||
});
|
||||
const expectation = expect(waitPromise).rejects.toThrow(
|
||||
'matrix account "sut" did not become ready',
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
await expectation;
|
||||
expect(gateway.call).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.call).toHaveBeenCalledWith(
|
||||
"channels.status",
|
||||
{ probe: false, timeoutMs: 250 },
|
||||
{ timeoutMs: 250 },
|
||||
);
|
||||
});
|
||||
});
|
||||
1352
extensions/qa-matrix/src/runners/contract/runtime.ts
Normal file
1352
extensions/qa-matrix/src/runners/contract/runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
1366
extensions/qa-matrix/src/runners/contract/scenario-catalog.ts
Normal file
1366
extensions/qa-matrix/src/runners/contract/scenario-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,144 @@
|
||||
// Qa Matrix plugin module implements scenario runtime allowbots behavior.
|
||||
import { MATRIX_QA_BOT_DM_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
|
||||
import {
|
||||
buildExactMarkerPrompt,
|
||||
buildMatrixQaToken,
|
||||
buildMentionPrompt,
|
||||
createMatrixQaScenarioClient,
|
||||
resolveMatrixQaNoReplyWindowMs,
|
||||
runNoReplyExpectedScenario,
|
||||
runTopologyScopedTopLevelScenario,
|
||||
type MatrixQaScenarioContext,
|
||||
} from "./scenario-runtime-shared.js";
|
||||
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
|
||||
|
||||
async function runObserverBotReplyScenario(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
roomKey?: string;
|
||||
tokenPrefix: string;
|
||||
withMention?: boolean;
|
||||
}) {
|
||||
return await runTopologyScopedTopLevelScenario({
|
||||
accessToken: params.context.observerAccessToken,
|
||||
actorId: "observer",
|
||||
actorUserId: params.context.observerUserId,
|
||||
context: params.context,
|
||||
roomKey: params.roomKey ?? params.context.topology.defaultRoomKey,
|
||||
tokenPrefix: params.tokenPrefix,
|
||||
...(params.withMention === undefined ? {} : { withMention: params.withMention }),
|
||||
});
|
||||
}
|
||||
|
||||
async function runObserverBotNoReplyScenario(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
roomKey?: string;
|
||||
tokenPrefix: string;
|
||||
withMention?: boolean;
|
||||
}) {
|
||||
const token = buildMatrixQaToken(params.tokenPrefix);
|
||||
const withMention = params.withMention !== false;
|
||||
return await runNoReplyExpectedScenario({
|
||||
accessToken: params.context.observerAccessToken,
|
||||
actorId: "observer",
|
||||
actorUserId: params.context.observerUserId,
|
||||
baseUrl: params.context.baseUrl,
|
||||
body: withMention
|
||||
? buildMentionPrompt(params.context.sutUserId, token)
|
||||
: buildExactMarkerPrompt(token),
|
||||
...(withMention ? { mentionUserIds: [params.context.sutUserId] } : {}),
|
||||
observedEvents: params.context.observedEvents,
|
||||
roomId: resolveMatrixQaScenarioRoomId(params.context, params.roomKey),
|
||||
syncState: params.context.syncState,
|
||||
syncStreams: params.context.syncStreams,
|
||||
sutUserId: params.context.sutUserId,
|
||||
timeoutMs: resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs),
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsDefaultBlockScenario(context: MatrixQaScenarioContext) {
|
||||
return await runObserverBotNoReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_DEFAULT_BLOCK",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsTrueUnmentionedOpenRoomScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
) {
|
||||
return await runObserverBotReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_TRUE_OPEN",
|
||||
withMention: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsMentionsMentionedRoomScenario(context: MatrixQaScenarioContext) {
|
||||
return await runObserverBotReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_MENTIONED",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsMentionsUnmentionedOpenRoomBlockScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
) {
|
||||
return await runObserverBotNoReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_OPEN_BLOCK",
|
||||
withMention: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsMentionsDmUnmentionedScenario(context: MatrixQaScenarioContext) {
|
||||
return await runObserverBotReplyScenario({
|
||||
context,
|
||||
roomKey: MATRIX_QA_BOT_DM_ROOM_KEY,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_DM",
|
||||
withMention: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsRoomOverrideBlocksAccountTrueScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
) {
|
||||
return await runObserverBotNoReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_ROOM_BLOCK",
|
||||
withMention: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsRoomOverrideEnablesAccountOffScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
) {
|
||||
return await runObserverBotReplyScenario({
|
||||
context,
|
||||
tokenPrefix: "MATRIX_QA_ALLOWBOTS_ROOM_ENABLE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAllowBotsSelfSenderIgnoredScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
const sutSender = createMatrixQaScenarioClient({
|
||||
accessToken: context.sutAccessToken,
|
||||
baseUrl: context.baseUrl,
|
||||
});
|
||||
const token = buildMatrixQaToken("MATRIX_QA_ALLOWBOTS_SELF_IGNORED");
|
||||
return await runNoReplyExpectedScenario({
|
||||
accessToken: context.observerAccessToken,
|
||||
actorId: "observer",
|
||||
actorUserId: context.sutUserId,
|
||||
baseUrl: context.baseUrl,
|
||||
body: buildExactMarkerPrompt(token),
|
||||
observedEvents: context.observedEvents,
|
||||
roomId: context.roomId,
|
||||
sendClient: sutSender,
|
||||
syncState: context.syncState,
|
||||
syncStreams: context.syncStreams,
|
||||
sutUserId: context.sutUserId,
|
||||
timeoutMs: resolveMatrixQaNoReplyWindowMs(context.timeoutMs),
|
||||
token,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
// Qa Matrix plugin module implements scenario runtime approval behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
|
||||
import { MATRIX_QA_DRIVER_DM_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
|
||||
import {
|
||||
advanceMatrixQaActorCursor,
|
||||
buildMatrixQaToken,
|
||||
createMatrixQaDriverScenarioClient,
|
||||
createMatrixQaScenarioClient,
|
||||
primeMatrixQaDriverScenarioClient,
|
||||
type MatrixQaScenarioContext,
|
||||
} from "./scenario-runtime-shared.js";
|
||||
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
|
||||
|
||||
const MATRIX_QA_APPROVAL_ALLOW_ONCE_REACTION = "✅";
|
||||
const MATRIX_QA_APPROVAL_DENY_REACTION = "❌";
|
||||
const MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS = 30_000;
|
||||
const MATRIX_QA_APPROVAL_SHORT_WINDOW_MS = 4_000;
|
||||
const MATRIX_QA_APPROVAL_LONG_COMMAND_TEXT = "matrix approval chunk fallback ".repeat(40);
|
||||
|
||||
type MatrixQaApprovalDecision = "allow-once" | "deny";
|
||||
type MatrixQaApprovalKind = "exec" | "plugin";
|
||||
type MatrixQaApprovalOptionReactionParams = {
|
||||
context: MatrixQaScenarioContext;
|
||||
emoji: string;
|
||||
roomId: string;
|
||||
targetEventId: string;
|
||||
};
|
||||
|
||||
function requireMatrixQaGatewayCall(context: MatrixQaScenarioContext) {
|
||||
if (!context.gatewayCall) {
|
||||
throw new Error("Matrix approval QA scenario requires a live gateway RPC client");
|
||||
}
|
||||
return context.gatewayCall;
|
||||
}
|
||||
|
||||
function buildMatrixApprovalArtifact(event: MatrixQaObservedEvent) {
|
||||
if (!event.approval) {
|
||||
throw new Error(`Matrix event ${event.eventId} did not include approval metadata`);
|
||||
}
|
||||
return {
|
||||
...event.approval,
|
||||
eventId: event.eventId,
|
||||
roomId: event.roomId,
|
||||
};
|
||||
}
|
||||
|
||||
function isApprovalOptionReaction(
|
||||
event: MatrixQaObservedEvent,
|
||||
params: MatrixQaApprovalOptionReactionParams,
|
||||
) {
|
||||
return (
|
||||
event.roomId === params.roomId &&
|
||||
event.sender === params.context.sutUserId &&
|
||||
event.type === "m.reaction" &&
|
||||
event.reaction?.eventId === params.targetEventId &&
|
||||
event.reaction.key === params.emoji
|
||||
);
|
||||
}
|
||||
|
||||
function hasObservedApprovalOptionReaction(params: MatrixQaApprovalOptionReactionParams) {
|
||||
return params.context.observedEvents.some((event) => isApprovalOptionReaction(event, params));
|
||||
}
|
||||
|
||||
function assertApprovalMetadata(params: {
|
||||
event: { approval?: unknown; eventId: string };
|
||||
expectedKind: MatrixQaApprovalKind;
|
||||
}) {
|
||||
const approval =
|
||||
typeof params.event.approval === "object" && params.event.approval !== null
|
||||
? (params.event.approval as {
|
||||
allowedDecisions?: string[];
|
||||
hasCommandText?: boolean;
|
||||
id?: string;
|
||||
kind?: string;
|
||||
state?: string;
|
||||
type?: string;
|
||||
version?: number;
|
||||
})
|
||||
: null;
|
||||
if (!approval) {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose metadata`);
|
||||
}
|
||||
if (approval.kind !== params.expectedKind) {
|
||||
throw new Error(
|
||||
`approval event ${params.event.eventId} kind was ${approval.kind ?? "<missing>"} instead of ${params.expectedKind}`,
|
||||
);
|
||||
}
|
||||
if (!approval.id) {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose an approval id`);
|
||||
}
|
||||
if (approval.version !== 1) {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose version=1`);
|
||||
}
|
||||
if (approval.type !== "approval.request") {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose type=approval.request`);
|
||||
}
|
||||
if (approval.state !== "pending") {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose state=pending`);
|
||||
}
|
||||
if (!approval.allowedDecisions?.includes("deny")) {
|
||||
throw new Error(`approval event ${params.event.eventId} did not include deny`);
|
||||
}
|
||||
if (
|
||||
params.expectedKind === "exec" &&
|
||||
(!approval.allowedDecisions.includes("allow-once") || approval.hasCommandText !== true)
|
||||
) {
|
||||
throw new Error(`approval event ${params.event.eventId} did not expose exec approval fields`);
|
||||
}
|
||||
}
|
||||
|
||||
function isExpectedApprovalEvent(
|
||||
event: MatrixQaObservedEvent,
|
||||
params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
expectedApprovalId: string;
|
||||
expectedKind: MatrixQaApprovalKind;
|
||||
roomId: string;
|
||||
threadRootEventId?: string;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
event.roomId === params.roomId &&
|
||||
event.sender === params.context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.approval?.kind === params.expectedKind &&
|
||||
event.approval.id === params.expectedApprovalId &&
|
||||
(!params.threadRootEventId || event.relatesTo?.eventId === params.threadRootEventId)
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForApprovalEvent(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
expectedApprovalId: string;
|
||||
expectedKind: MatrixQaApprovalKind;
|
||||
roomId: string;
|
||||
since?: string;
|
||||
threadRootEventId?: string;
|
||||
}) {
|
||||
const observedMatch = params.context.observedEvents.find((event) =>
|
||||
isExpectedApprovalEvent(event, params),
|
||||
);
|
||||
if (observedMatch) {
|
||||
assertApprovalMetadata({
|
||||
event: observedMatch,
|
||||
expectedKind: params.expectedKind,
|
||||
});
|
||||
return {
|
||||
event: observedMatch,
|
||||
since: params.since,
|
||||
};
|
||||
}
|
||||
const client = createMatrixQaScenarioClient({
|
||||
accessToken: params.context.driverAccessToken,
|
||||
baseUrl: params.context.baseUrl,
|
||||
});
|
||||
const matched = await client.waitForRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) => isExpectedApprovalEvent(event, params),
|
||||
roomId: params.roomId,
|
||||
since: params.since,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
});
|
||||
assertApprovalMetadata({
|
||||
event: matched.event,
|
||||
expectedKind: params.expectedKind,
|
||||
});
|
||||
return matched;
|
||||
}
|
||||
|
||||
async function waitForObservedApprovalEvent(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
expectedApprovalId: string;
|
||||
expectedKind: MatrixQaApprovalKind;
|
||||
excludedRoomIds?: string[];
|
||||
roomIds: string[];
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const client = createMatrixQaDriverScenarioClient(params.context);
|
||||
const roomIds = normalizeUniqueStringEntries(params.roomIds);
|
||||
const primaryRoomId = roomIds[0];
|
||||
if (!primaryRoomId) {
|
||||
throw new Error("Matrix approval wait requires at least one candidate room");
|
||||
}
|
||||
const excludedRoomIds = new Set(params.excludedRoomIds ?? []);
|
||||
const isExpectedObservedApproval = (event: MatrixQaObservedEvent) => {
|
||||
if (excludedRoomIds.has(event.roomId)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
roomIds.some((roomId) =>
|
||||
isExpectedApprovalEvent(event, {
|
||||
...params,
|
||||
roomId,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
event.sender === params.context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.approval?.kind === params.expectedKind &&
|
||||
event.approval.id === params.expectedApprovalId
|
||||
);
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const observedMatch = params.context.observedEvents.find(isExpectedObservedApproval);
|
||||
if (observedMatch) {
|
||||
assertApprovalMetadata({
|
||||
event: observedMatch,
|
||||
expectedKind: params.expectedKind,
|
||||
});
|
||||
return {
|
||||
event: observedMatch,
|
||||
since: undefined,
|
||||
};
|
||||
}
|
||||
const remainingMs = params.timeoutMs - (Date.now() - startedAt);
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await client.waitForOptionalRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: isExpectedObservedApproval,
|
||||
roomId: primaryRoomId,
|
||||
timeoutMs: Math.min(1_000, remainingMs),
|
||||
});
|
||||
await sleep(Math.min(100, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`timed out waiting for observed Matrix approval ${params.expectedApprovalId} in ${roomIds.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function listDriverDmApprovalCandidateRoomIds(context: MatrixQaScenarioContext) {
|
||||
const preferredRoomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
|
||||
return [
|
||||
preferredRoomId,
|
||||
...context.topology.rooms
|
||||
.filter(
|
||||
(room) =>
|
||||
room.kind === "dm" &&
|
||||
room.memberRoles.includes("driver") &&
|
||||
room.memberRoles.includes("sut"),
|
||||
)
|
||||
.map((room) => room.roomId),
|
||||
];
|
||||
}
|
||||
|
||||
async function reactToApproval(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
decision: MatrixQaApprovalDecision;
|
||||
roomId: string;
|
||||
targetEventId: string;
|
||||
}) {
|
||||
const client = createMatrixQaDriverScenarioClient(params.context);
|
||||
const emoji =
|
||||
params.decision === "allow-once"
|
||||
? MATRIX_QA_APPROVAL_ALLOW_ONCE_REACTION
|
||||
: MATRIX_QA_APPROVAL_DENY_REACTION;
|
||||
if (
|
||||
!hasObservedApprovalOptionReaction({
|
||||
context: params.context,
|
||||
emoji,
|
||||
roomId: params.roomId,
|
||||
targetEventId: params.targetEventId,
|
||||
})
|
||||
) {
|
||||
await client.waitForRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) =>
|
||||
isApprovalOptionReaction(event, {
|
||||
context: params.context,
|
||||
emoji,
|
||||
roomId: params.roomId,
|
||||
targetEventId: params.targetEventId,
|
||||
}),
|
||||
roomId: params.roomId,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
});
|
||||
}
|
||||
const eventId = await client.sendReaction({
|
||||
emoji,
|
||||
messageId: params.targetEventId,
|
||||
roomId: params.roomId,
|
||||
});
|
||||
await client
|
||||
.waitForRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.roomId === params.roomId &&
|
||||
event.sender === params.context.driverUserId &&
|
||||
event.type === "m.reaction" &&
|
||||
event.reaction?.eventId === params.targetEventId &&
|
||||
event.reaction.key === emoji,
|
||||
roomId: params.roomId,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
throw new Error(
|
||||
`Matrix approval reaction ${eventId} was not observed before waiting for the gateway decision: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
return {
|
||||
eventId,
|
||||
reaction: {
|
||||
eventId: params.targetEventId,
|
||||
key: emoji,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertApprovalDecisionResult(params: {
|
||||
approvalId: string;
|
||||
decision: MatrixQaApprovalDecision;
|
||||
result: unknown;
|
||||
}) {
|
||||
const result =
|
||||
typeof params.result === "object" && params.result !== null
|
||||
? (params.result as { decision?: unknown; id?: unknown })
|
||||
: null;
|
||||
if (result?.id !== params.approvalId) {
|
||||
throw new Error(
|
||||
`approval decision result id was ${formatApprovalResultValue(result?.id)} instead of ${params.approvalId}`,
|
||||
);
|
||||
}
|
||||
if (result?.decision !== params.decision) {
|
||||
throw new Error(
|
||||
`approval decision was ${formatApprovalResultValue(result?.decision)} instead of ${params.decision}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatApprovalResultValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (value == null) {
|
||||
return "<missing>";
|
||||
}
|
||||
return JSON.stringify(value) ?? "<unserializable>";
|
||||
}
|
||||
|
||||
async function requestExecApproval(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
command: string;
|
||||
id?: string;
|
||||
threadRootEventId?: string;
|
||||
}) {
|
||||
const gatewayCall = requireMatrixQaGatewayCall(params.context);
|
||||
return await gatewayCall(
|
||||
"exec.approval.request",
|
||||
{
|
||||
...(params.id ? { id: params.id } : {}),
|
||||
ask: "always",
|
||||
command: params.command,
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
twoPhase: true,
|
||||
turnSourceAccountId: params.context.sutAccountId,
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: `room:${params.context.roomId}`,
|
||||
...(params.threadRootEventId ? { turnSourceThreadId: params.threadRootEventId } : {}),
|
||||
},
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function requestPluginApproval(params: { context: MatrixQaScenarioContext; token: string }) {
|
||||
const gatewayCall = requireMatrixQaGatewayCall(params.context);
|
||||
return await gatewayCall(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
agentId: "qa",
|
||||
description: `Matrix plugin approval QA request ${params.token}`,
|
||||
pluginId: "qa-matrix-plugin",
|
||||
severity: "warning",
|
||||
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS,
|
||||
title: "Matrix plugin approval QA",
|
||||
toolName: "matrix_qa_tool",
|
||||
twoPhase: true,
|
||||
turnSourceAccountId: params.context.sutAccountId,
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: `room:${params.context.roomId}`,
|
||||
},
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
kind: MatrixQaApprovalKind;
|
||||
}) {
|
||||
const gatewayCall = requireMatrixQaGatewayCall(params.context);
|
||||
const method =
|
||||
params.kind === "exec" ? "exec.approval.waitDecision" : "plugin.approval.waitDecision";
|
||||
return await gatewayCall(
|
||||
method,
|
||||
{ id: params.approvalId },
|
||||
{
|
||||
expectFinal: true,
|
||||
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveApprovalDecision(params: {
|
||||
approvalId: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
decision: MatrixQaApprovalDecision;
|
||||
kind: MatrixQaApprovalKind;
|
||||
}) {
|
||||
const gatewayCall = requireMatrixQaGatewayCall(params.context);
|
||||
const method = params.kind === "exec" ? "exec.approval.resolve" : "plugin.approval.resolve";
|
||||
return await gatewayCall(
|
||||
method,
|
||||
{ decision: params.decision, id: params.approvalId },
|
||||
{
|
||||
expectFinal: false,
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequest(result: unknown) {
|
||||
const accepted =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as { id?: unknown; status?: unknown })
|
||||
: null;
|
||||
if (accepted?.status !== "accepted") {
|
||||
throw new Error(
|
||||
`approval request status was ${formatApprovalResultValue(accepted?.status)} instead of accepted`,
|
||||
);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function assertAcceptedApprovalRequest(params: { approvalId: string; result: unknown }) {
|
||||
const id = readAcceptedApprovalRequest(params.result).id;
|
||||
if (id !== params.approvalId) {
|
||||
throw new Error(
|
||||
`accepted approval id was ${formatApprovalResultValue(id)} instead of ${params.approvalId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readAcceptedApprovalRequestId(result: unknown) {
|
||||
const id = readAcceptedApprovalRequest(result).id;
|
||||
if (typeof id !== "string" || !id.trim()) {
|
||||
throw new Error("approval request did not return an accepted approval id");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function buildExecApprovalCommand(params: { expectChunk?: boolean; token: string }) {
|
||||
if (!params.expectChunk) {
|
||||
return `printf ${params.token}`;
|
||||
}
|
||||
return `printf '${params.token} ${MATRIX_QA_APPROVAL_LONG_COMMAND_TEXT}'`;
|
||||
}
|
||||
|
||||
async function runExecApprovalScenario(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
decision: MatrixQaApprovalDecision;
|
||||
expectChunk?: boolean;
|
||||
tokenPrefix: string;
|
||||
threadRootEventId?: string;
|
||||
}) {
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(params.context);
|
||||
const token = buildMatrixQaToken(params.tokenPrefix);
|
||||
const command = buildExecApprovalCommand({ expectChunk: params.expectChunk, token });
|
||||
const approvalId = `qa-${token.toLowerCase()}-${randomUUID().slice(0, 8)}`;
|
||||
const accepted = await requestExecApproval({
|
||||
context: params.context,
|
||||
command,
|
||||
id: approvalId,
|
||||
threadRootEventId: params.threadRootEventId,
|
||||
});
|
||||
assertAcceptedApprovalRequest({ approvalId, result: accepted });
|
||||
const approval = await waitForApprovalEvent({
|
||||
context: params.context,
|
||||
expectedApprovalId: approvalId,
|
||||
expectedKind: "exec",
|
||||
roomId: params.context.roomId,
|
||||
since: startSince,
|
||||
threadRootEventId: params.threadRootEventId,
|
||||
});
|
||||
if (params.expectChunk) {
|
||||
const chunk = await client.waitForRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.roomId === params.context.roomId &&
|
||||
event.sender === params.context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.body?.includes(token) === true &&
|
||||
event.eventId !== approval.event.eventId &&
|
||||
event.approval === undefined,
|
||||
roomId: params.context.roomId,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
});
|
||||
if (chunk.event.approval) {
|
||||
throw new Error(`chunk event ${chunk.event.eventId} unexpectedly duplicated metadata`);
|
||||
}
|
||||
}
|
||||
const reaction = await reactToApproval({
|
||||
context: params.context,
|
||||
decision: params.decision,
|
||||
roomId: params.context.roomId,
|
||||
targetEventId: approval.event.eventId,
|
||||
});
|
||||
const result = await waitForApprovalDecision({
|
||||
approvalId,
|
||||
context: params.context,
|
||||
kind: "exec",
|
||||
});
|
||||
assertApprovalDecisionResult({
|
||||
approvalId,
|
||||
decision: params.decision,
|
||||
result,
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: params.context.syncState,
|
||||
nextSince: approval.since,
|
||||
startSince,
|
||||
});
|
||||
return {
|
||||
artifacts: {
|
||||
approval: buildMatrixApprovalArtifact(approval.event),
|
||||
reactionEmoji: reaction.reaction?.key,
|
||||
reactionEventId: reaction.eventId,
|
||||
reactionTargetEventId: reaction.reaction?.eventId,
|
||||
token,
|
||||
},
|
||||
details: [
|
||||
`approval event: ${approval.event.eventId}`,
|
||||
`approval id: ${approvalId}`,
|
||||
`approval kind: ${approval.event.approval?.kind ?? "<missing>"}`,
|
||||
`decision: ${params.decision}`,
|
||||
`reaction event: ${reaction.eventId}`,
|
||||
`reaction target: ${reaction.reaction?.eventId ?? "<missing>"}`,
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runApprovalExecMetadataSingleEventScenario(context: MatrixQaScenarioContext) {
|
||||
return await runExecApprovalScenario({
|
||||
context,
|
||||
decision: "allow-once",
|
||||
tokenPrefix: "MATRIX_QA_APPROVAL_EXEC",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runApprovalExecMetadataChunkedScenario(context: MatrixQaScenarioContext) {
|
||||
return await runExecApprovalScenario({
|
||||
context,
|
||||
decision: "allow-once",
|
||||
expectChunk: true,
|
||||
tokenPrefix: "MATRIX_QA_APPROVAL_CHUNKED",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runApprovalDenyReactionScenario(context: MatrixQaScenarioContext) {
|
||||
return await runExecApprovalScenario({
|
||||
context,
|
||||
decision: "deny",
|
||||
tokenPrefix: "MATRIX_QA_APPROVAL_DENY",
|
||||
});
|
||||
}
|
||||
|
||||
export async function runApprovalThreadTargetScenario(context: MatrixQaScenarioContext) {
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const token = buildMatrixQaToken("MATRIX_QA_APPROVAL_THREAD_ROOT");
|
||||
const rootEventId = await client.sendTextMessage({
|
||||
body: `Matrix approval thread root ${token}`,
|
||||
roomId: context.roomId,
|
||||
});
|
||||
const result = await runExecApprovalScenario({
|
||||
context,
|
||||
decision: "allow-once",
|
||||
threadRootEventId: rootEventId,
|
||||
tokenPrefix: "MATRIX_QA_APPROVAL_THREAD",
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: context.syncState,
|
||||
startSince,
|
||||
});
|
||||
return {
|
||||
artifacts: {
|
||||
...result.artifacts,
|
||||
rootEventId,
|
||||
},
|
||||
details: [result.details, `thread root event: ${rootEventId}`].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runApprovalPluginMetadataSingleEventScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
) {
|
||||
const { startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const token = buildMatrixQaToken("MATRIX_QA_PLUGIN_APPROVAL");
|
||||
const accepted = await requestPluginApproval({ context, token });
|
||||
const approvalId = readAcceptedApprovalRequestId(accepted);
|
||||
const approval = await waitForApprovalEvent({
|
||||
context,
|
||||
expectedApprovalId: approvalId,
|
||||
expectedKind: "plugin",
|
||||
roomId: context.roomId,
|
||||
since: startSince,
|
||||
});
|
||||
const approvalMetadata = approval.event.approval;
|
||||
if (
|
||||
approvalMetadata?.pluginId !== "qa-matrix-plugin" ||
|
||||
approvalMetadata.toolName !== "matrix_qa_tool" ||
|
||||
approvalMetadata.severity !== "warning" ||
|
||||
approvalMetadata.agentId !== "qa"
|
||||
) {
|
||||
throw new Error(`plugin approval event ${approval.event.eventId} did not expose plugin fields`);
|
||||
}
|
||||
const reaction = await reactToApproval({
|
||||
context,
|
||||
decision: "allow-once",
|
||||
roomId: context.roomId,
|
||||
targetEventId: approval.event.eventId,
|
||||
});
|
||||
const result = await waitForApprovalDecision({
|
||||
approvalId,
|
||||
context,
|
||||
kind: "plugin",
|
||||
});
|
||||
assertApprovalDecisionResult({
|
||||
approvalId,
|
||||
decision: "allow-once",
|
||||
result,
|
||||
});
|
||||
return {
|
||||
artifacts: {
|
||||
approval: buildMatrixApprovalArtifact(approval.event),
|
||||
reactionEmoji: reaction.reaction?.key,
|
||||
reactionEventId: reaction.eventId,
|
||||
reactionTargetEventId: reaction.reaction?.eventId,
|
||||
token,
|
||||
},
|
||||
details: [
|
||||
`approval event: ${approval.event.eventId}`,
|
||||
`approval id: ${approvalMetadata.id}`,
|
||||
`plugin id: ${approvalMetadata.pluginId ?? "<missing>"}`,
|
||||
`tool name: ${approvalMetadata.toolName ?? "<missing>"}`,
|
||||
`decision: allow-once`,
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runApprovalChannelTargetBothScenario(context: MatrixQaScenarioContext) {
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const dmRoomIds = listDriverDmApprovalCandidateRoomIds(context);
|
||||
const token = buildMatrixQaToken("MATRIX_QA_APPROVAL_BOTH");
|
||||
const approvalId = `qa-${token.toLowerCase()}-${randomUUID().slice(0, 8)}`;
|
||||
const accepted = await requestExecApproval({
|
||||
context,
|
||||
command: `printf ${token}`,
|
||||
id: approvalId,
|
||||
});
|
||||
assertAcceptedApprovalRequest({ approvalId, result: accepted });
|
||||
const channelApproval = await waitForApprovalEvent({
|
||||
context,
|
||||
expectedApprovalId: approvalId,
|
||||
expectedKind: "exec",
|
||||
roomId: context.roomId,
|
||||
since: startSince,
|
||||
});
|
||||
const dmApproval = await waitForObservedApprovalEvent({
|
||||
context,
|
||||
excludedRoomIds: [context.roomId],
|
||||
expectedApprovalId: approvalId,
|
||||
expectedKind: "exec",
|
||||
roomIds: dmRoomIds,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
if (channelApproval.event.approval?.id !== dmApproval.event.approval?.id) {
|
||||
throw new Error("target=both delivered different approval ids to channel and DM");
|
||||
}
|
||||
await resolveApprovalDecision({
|
||||
approvalId,
|
||||
context,
|
||||
decision: "allow-once",
|
||||
kind: "exec",
|
||||
});
|
||||
const lateDuplicate = await client.waitForOptionalRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.sender === context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.approval?.id === approvalId &&
|
||||
event.eventId !== channelApproval.event.eventId &&
|
||||
event.eventId !== dmApproval.event.eventId,
|
||||
roomId: context.roomId,
|
||||
timeoutMs: MATRIX_QA_APPROVAL_SHORT_WINDOW_MS,
|
||||
});
|
||||
if (lateDuplicate.matched) {
|
||||
throw new Error(`approval ${approvalId} was re-delivered after resolution`);
|
||||
}
|
||||
return {
|
||||
artifacts: {
|
||||
approvals: [
|
||||
buildMatrixApprovalArtifact(channelApproval.event),
|
||||
buildMatrixApprovalArtifact(dmApproval.event),
|
||||
],
|
||||
token,
|
||||
},
|
||||
details: [
|
||||
`channel approval event: ${channelApproval.event.eventId}`,
|
||||
`dm approval event: ${dmApproval.event.eventId}`,
|
||||
`approval id: ${approvalId}`,
|
||||
`cleanup decision: allow-once`,
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// Qa Matrix tests cover scenario runtime cli plugin behavior.
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
resolveMatrixQaOpenClawCliEntryPath,
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
testing,
|
||||
} from "./scenario-runtime-cli.js";
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFile(pathToCheck: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await readFile(pathToCheck, "utf8");
|
||||
return;
|
||||
} catch {
|
||||
await sleep(25);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${pathToCheck}`);
|
||||
}
|
||||
|
||||
describe("Matrix QA CLI runtime", () => {
|
||||
it("redacts secret CLI arguments in diagnostic command text", () => {
|
||||
expect(
|
||||
formatMatrixQaCliCommand([
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--recovery-key",
|
||||
"abcdef1234567890ghij",
|
||||
]),
|
||||
).toBe("openclaw matrix verify backup restore --recovery-key [REDACTED]");
|
||||
expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe(
|
||||
"openclaw matrix account add --access-token=[REDACTED]",
|
||||
);
|
||||
expect(
|
||||
formatMatrixQaCliCommand(["matrix", "verify", "device", "abcdef1234567890ghij", "--json"]),
|
||||
).toBe("openclaw matrix verify device [REDACTED] --json");
|
||||
});
|
||||
|
||||
it("redacts Matrix token output before diagnostics and artifacts", () => {
|
||||
expect(
|
||||
redactMatrixQaCliOutput("GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"),
|
||||
).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij");
|
||||
});
|
||||
|
||||
it("force-kills Windows CLI process trees when graceful taskkill fails", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
try {
|
||||
const killMock = vi.fn();
|
||||
const child = {
|
||||
pid: 12345,
|
||||
kill: killMock,
|
||||
} as unknown as Parameters<typeof testing.killMatrixQaCliChild>[0];
|
||||
const runTaskkill = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1 })
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
testing.killMatrixQaCliChild(child, "SIGTERM", runTaskkill);
|
||||
|
||||
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(killMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
if (originalSystemRoot === undefined) {
|
||||
delete process.env.SystemRoot;
|
||||
} else {
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
}
|
||||
if (originalWindir === undefined) {
|
||||
delete process.env.WINDIR;
|
||||
} else {
|
||||
process.env.WINDIR = originalWindir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the ESM OpenClaw CLI entrypoint when present", async () => {
|
||||
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-entry-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(path.join(root, "dist", "index.mjs"), "");
|
||||
expect(resolveMatrixQaOpenClawCliEntryPath(root)).toBe(path.join(root, "dist", "index.mjs"));
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can preserve expected non-zero CLI output for negative scenarios", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-nonzero-"),
|
||||
);
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"process.stdout.write(JSON.stringify({ success: false, error: 'expected failure' }));",
|
||||
"process.exit(7);",
|
||||
].join("\n"),
|
||||
);
|
||||
const result = await runMatrixQaOpenClawCli({
|
||||
allowNonZero: true,
|
||||
args: ["matrix", "verify", "backup", "restore", "--json"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.stdout).toContain('"success":false');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can pass stdin to CLI commands", async () => {
|
||||
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-stdin-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"let input = '';",
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk; });",
|
||||
"process.stdin.on('end', () => {",
|
||||
" process.stdout.write(JSON.stringify({ input: input.trim() }));",
|
||||
"});",
|
||||
].join("\n"),
|
||||
);
|
||||
const result = await runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "backup", "restore", "--recovery-key-stdin", "--json"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdin: "stdin-recovery-key\n",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(result.stdout).toContain('"input":"stdin-recovery-key"');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can close stdin after interactive CLI prompts", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-interactive-"),
|
||||
);
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"let input = '';",
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk; process.stdout.write('prompt answered\\n'); });",
|
||||
"process.stdin.on('end', () => {",
|
||||
" process.stdout.write(JSON.stringify({ input: input.trim(), ended: true }));",
|
||||
"});",
|
||||
].join("\n"),
|
||||
);
|
||||
const session = startMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await session.writeStdin("yes\n");
|
||||
await session.waitForOutput(
|
||||
(output) => output.text.includes("prompt answered"),
|
||||
"interactive prompt acknowledgement",
|
||||
5_000,
|
||||
);
|
||||
session.endStdin();
|
||||
const result = await session.wait();
|
||||
|
||||
expect(result.stdout).toContain('"input":"yes"');
|
||||
expect(result.stdout).toContain('"ended":true');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes timed-out CLI output in diagnostics", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-"),
|
||||
);
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"process.stdout.write('waiting for verification\\n');",
|
||||
"process.stderr.write('matrix sdk still syncing\\n');",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 250,
|
||||
}),
|
||||
).rejects.toThrow(/stdout:\nwaiting for verification/);
|
||||
await expect(
|
||||
runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 250,
|
||||
}),
|
||||
).rejects.toThrow(/stderr:\nmatrix sdk still syncing/);
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills CLI commands that ignore graceful timeout termination", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-kill-"),
|
||||
);
|
||||
const pidPath = path.join(root, "cli.pid");
|
||||
let childPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`,
|
||||
"process.stdout.write('waiting despite graceful shutdown\\n');",
|
||||
"process.on('SIGTERM', () => { process.stdout.write('ignored sigterm\\n'); });",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
).rejects.toThrow(/timed out after 500ms/u);
|
||||
|
||||
childPid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
} finally {
|
||||
if (childPid && isProcessRunning(childPid)) {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves timeout diagnostics when wait attaches after timeout", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-late-wait-timeout-"),
|
||||
);
|
||||
const pidPath = path.join(root, "cli.pid");
|
||||
let childPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`,
|
||||
"process.stdout.write('late wait timeout marker\\n');",
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const session = startMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
await sleep(850);
|
||||
|
||||
await expect(session.wait()).rejects.toThrow(/timed out after 500ms/u);
|
||||
await expect(session.wait()).rejects.toThrow(/late wait timeout marker/u);
|
||||
|
||||
childPid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
} finally {
|
||||
if (childPid && isProcessRunning(childPid)) {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("settles and kills descendants that keep timed-out CLI stdio open", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-tree-"),
|
||||
);
|
||||
const childPidPath = path.join(root, "child.pid");
|
||||
const grandchildPidPath = path.join(root, "grandchild.pid");
|
||||
let childPid: number | undefined;
|
||||
let grandchildPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
|
||||
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { stdio: ['ignore', 'inherit', 'inherit'] });",
|
||||
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
|
||||
"process.stdout.write('spawned persistent descendant\\n');",
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
).rejects.toThrow(/timed out after 500ms/u);
|
||||
|
||||
childPid = Number(await readFile(childPidPath, "utf8"));
|
||||
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
if (process.platform !== "win32") {
|
||||
expect(isProcessRunning(grandchildPid)).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
for (const pid of [grandchildPid, childPid]) {
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills ignored-stdio descendants after a timed-out CLI exits gracefully", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-ignored-stdio-"),
|
||||
);
|
||||
const childPidPath = path.join(root, "child.pid");
|
||||
const grandchildPidPath = path.join(root, "grandchild.pid");
|
||||
let childPid: number | undefined;
|
||||
let grandchildPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
|
||||
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
|
||||
"grandchild.unref();",
|
||||
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
|
||||
"process.on('SIGTERM', () => process.exit(0));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const run = runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
await waitForFile(grandchildPidPath, 2_000);
|
||||
|
||||
await expect(run).rejects.toThrow(/timed out after 500ms/u);
|
||||
|
||||
childPid = Number(await readFile(childPidPath, "utf8"));
|
||||
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
expect(isProcessRunning(grandchildPid)).toBe(false);
|
||||
} finally {
|
||||
for (const pid of [grandchildPid, childPid]) {
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills ignored-stdio descendants after manual CLI session kill", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-session-kill-ignored-stdio-"),
|
||||
);
|
||||
const childPidPath = path.join(root, "child.pid");
|
||||
const grandchildPidPath = path.join(root, "grandchild.pid");
|
||||
let childPid: number | undefined;
|
||||
let grandchildPid: number | undefined;
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
|
||||
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
|
||||
"grandchild.unref();",
|
||||
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
|
||||
"process.on('SIGTERM', () => process.exit(0));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const session = startMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await waitForFile(grandchildPidPath, 2_000);
|
||||
await sleep(300);
|
||||
|
||||
session.kill();
|
||||
await sleep(500);
|
||||
|
||||
childPid = Number(await readFile(childPidPath, "utf8"));
|
||||
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
|
||||
expect(isProcessRunning(childPid)).toBe(false);
|
||||
expect(isProcessRunning(grandchildPid)).toBe(false);
|
||||
} finally {
|
||||
for (const pid of [grandchildPid, childPid]) {
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
// Qa Matrix plugin module implements scenario runtime cli behavior.
|
||||
import { spawn as startOpenClawCliProcess, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveMatrixQaWindowsSystem32ExePath } from "../../windows-system-tools.js";
|
||||
|
||||
export type MatrixQaCliRunResult = {
|
||||
args: string[];
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
export type MatrixQaCliSession = {
|
||||
args: string[];
|
||||
endStdin: () => void;
|
||||
output: () => { stderr: string; stdout: string };
|
||||
wait: () => Promise<MatrixQaCliRunResult>;
|
||||
waitForOutput: (
|
||||
predicate: (output: { stderr: string; stdout: string; text: string }) => boolean,
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
) => Promise<{ stderr: string; stdout: string; text: string }>;
|
||||
writeStdin: (text: string) => Promise<void>;
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]);
|
||||
const MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS = 250;
|
||||
const MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS = 100;
|
||||
|
||||
function isMatrixQaCliSecretPositionalArg(args: string[], index: number): boolean {
|
||||
return args[0] === "matrix" && args[1] === "verify" && args[2] === "device" && index === 3;
|
||||
}
|
||||
|
||||
function redactMatrixQaCliArgs(args: string[]): string[] {
|
||||
return args.map((arg, index) => {
|
||||
const [flag] = arg.split("=", 1);
|
||||
if (MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(flag) && arg.includes("=")) {
|
||||
return `${flag}=[REDACTED]`;
|
||||
}
|
||||
const previous = args[index - 1];
|
||||
if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
if (isMatrixQaCliSecretPositionalArg(args, index)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
export function redactMatrixQaCliOutput(text: string): string {
|
||||
return redactSensitiveText(text);
|
||||
}
|
||||
|
||||
export function formatMatrixQaCliCommand(args: string[]) {
|
||||
return `openclaw ${redactMatrixQaCliArgs(args).join(" ")}`;
|
||||
}
|
||||
|
||||
export function resolveMatrixQaOpenClawCliEntryPath(cwd: string): string {
|
||||
const mjsEntryPath = path.join(cwd, "dist", "index.mjs");
|
||||
if (existsSync(mjsEntryPath)) {
|
||||
return mjsEntryPath;
|
||||
}
|
||||
return path.join(cwd, "dist", "index.js");
|
||||
}
|
||||
|
||||
function buildMatrixQaCliResult(params: {
|
||||
args: string[];
|
||||
exitCode: number;
|
||||
output: { stderr: string; stdout: string };
|
||||
}): MatrixQaCliRunResult {
|
||||
return {
|
||||
args: params.args,
|
||||
exitCode: params.exitCode,
|
||||
stderr: params.output.stderr,
|
||||
stdout: params.output.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) {
|
||||
return [
|
||||
`${formatMatrixQaCliCommand(result.args)} exited ${result.exitCode}`,
|
||||
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
|
||||
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatMatrixQaCliTimeoutError(result: MatrixQaCliRunResult, timeoutMs: number) {
|
||||
return [
|
||||
`${formatMatrixQaCliCommand(result.args)} timed out after ${timeoutMs}ms`,
|
||||
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
|
||||
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function killMatrixQaCliChild(
|
||||
child: ReturnType<typeof startOpenClawCliProcess>,
|
||||
signal: NodeJS.Signals,
|
||||
runTaskkill: typeof spawnSync = spawnSync,
|
||||
): void {
|
||||
if (process.platform === "win32") {
|
||||
if (child.pid) {
|
||||
const taskkillPath = resolveMatrixQaWindowsSystem32ExePath("taskkill.exe");
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
|
||||
if (!result.error && result.status === 0) {
|
||||
return;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!forceResult.error && forceResult.status === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child if process-group signaling is unavailable.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function isMatrixQaCliChildProcessGroupRunning(
|
||||
child: ReturnType<typeof startOpenClawCliProcess>,
|
||||
): boolean {
|
||||
if (process.platform === "win32" || !child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startMatrixQaOpenClawCli(params: {
|
||||
allowNonZero?: boolean;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
timeoutMs: number;
|
||||
}): MatrixQaCliSession {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const distEntryPath = resolveMatrixQaOpenClawCliEntryPath(cwd);
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let closed = false;
|
||||
let closeError: Error | undefined;
|
||||
let closeResult: MatrixQaCliRunResult | undefined;
|
||||
let killRequested = false;
|
||||
let timedOut = false;
|
||||
let forceKillTimeout: NodeJS.Timeout | undefined;
|
||||
let forceSettleTimeout: NodeJS.Timeout | undefined;
|
||||
let settleWait:
|
||||
| {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (result: MatrixQaCliRunResult) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const child = startOpenClawCliProcess(process.execPath, [distEntryPath, ...params.args], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: params.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const readOutput = () => ({
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
});
|
||||
const finish = (result: MatrixQaCliRunResult, error?: Error) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
closeError = error;
|
||||
closeResult = result;
|
||||
if (!settleWait) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
settleWait.reject(error);
|
||||
} else {
|
||||
settleWait.resolve(result);
|
||||
}
|
||||
};
|
||||
const finishTimeout = (result: MatrixQaCliRunResult) => {
|
||||
finish(result, new Error(formatMatrixQaCliTimeoutError(result, params.timeoutMs)));
|
||||
};
|
||||
const finishResult = (result: MatrixQaCliRunResult) => {
|
||||
if (result.exitCode !== 0 && params.allowNonZero !== true) {
|
||||
finish(result, new Error(formatMatrixQaCliExitError(result)));
|
||||
return;
|
||||
}
|
||||
finish(result);
|
||||
};
|
||||
const clearForcedTimeouts = () => {
|
||||
if (forceKillTimeout) {
|
||||
clearTimeout(forceKillTimeout);
|
||||
forceKillTimeout = undefined;
|
||||
}
|
||||
if (forceSettleTimeout) {
|
||||
clearTimeout(forceSettleTimeout);
|
||||
forceSettleTimeout = undefined;
|
||||
}
|
||||
};
|
||||
const finishForcedCleanup = (result: MatrixQaCliRunResult) => {
|
||||
if (timedOut) {
|
||||
finishTimeout(result);
|
||||
return;
|
||||
}
|
||||
finishResult(result);
|
||||
};
|
||||
const scheduleForcedCleanup = () => {
|
||||
if (forceKillTimeout || forceSettleTimeout) {
|
||||
return;
|
||||
}
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
forceKillTimeout = undefined;
|
||||
killMatrixQaCliChild(child, "SIGKILL");
|
||||
forceSettleTimeout = setTimeout(() => {
|
||||
forceSettleTimeout = undefined;
|
||||
finishForcedCleanup(
|
||||
buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: 1,
|
||||
output: readOutput(),
|
||||
}),
|
||||
);
|
||||
}, MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS);
|
||||
}, MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killMatrixQaCliChild(child, "SIGTERM");
|
||||
scheduleForcedCleanup();
|
||||
}, params.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
||||
if (params.stdin !== undefined) {
|
||||
child.stdin.end(params.stdin);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
clearForcedTimeouts();
|
||||
finish(
|
||||
buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: 1,
|
||||
output: readOutput(),
|
||||
}),
|
||||
error,
|
||||
);
|
||||
});
|
||||
child.on("close", (exitCode) => {
|
||||
clearTimeout(timeout);
|
||||
const result = buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: exitCode ?? 1,
|
||||
output: readOutput(),
|
||||
});
|
||||
if (timedOut || killRequested) {
|
||||
// A closed parent is not proof that detached, ignored-stdio descendants are gone.
|
||||
if (isMatrixQaCliChildProcessGroupRunning(child)) {
|
||||
return;
|
||||
}
|
||||
clearForcedTimeouts();
|
||||
finishForcedCleanup(result);
|
||||
return;
|
||||
}
|
||||
clearForcedTimeouts();
|
||||
finishResult(result);
|
||||
});
|
||||
|
||||
return {
|
||||
args: params.args,
|
||||
endStdin: () => {
|
||||
if (!child.stdin.destroyed) {
|
||||
child.stdin.end();
|
||||
}
|
||||
},
|
||||
output: readOutput,
|
||||
wait: async () =>
|
||||
await new Promise<MatrixQaCliRunResult>((resolve, reject) => {
|
||||
if (closed && closeResult) {
|
||||
if (closeError) {
|
||||
reject(closeError);
|
||||
} else if (closeResult.exitCode === 0 || params.allowNonZero === true) {
|
||||
resolve(closeResult);
|
||||
} else {
|
||||
reject(new Error(formatMatrixQaCliExitError(closeResult)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
settleWait = { reject, resolve };
|
||||
}).catch((error: unknown) => {
|
||||
throw new Error(
|
||||
`Matrix QA CLI command failed (${formatMatrixQaCliCommand(params.args)}): ${redactMatrixQaCliOutput(formatErrorMessage(error))}`,
|
||||
);
|
||||
}),
|
||||
waitForOutput: async (predicate, label, timeoutMs) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const output = readOutput();
|
||||
const text = `${output.stdout}\n${output.stderr}`;
|
||||
if (predicate({ ...output, text })) {
|
||||
return { ...output, text };
|
||||
}
|
||||
if (closed) {
|
||||
break;
|
||||
}
|
||||
await sleep(Math.min(100, Math.max(25, timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
const output = readOutput();
|
||||
throw new Error(
|
||||
`${formatMatrixQaCliCommand(params.args)} did not print ${label} before timeout\nstdout:\n${redactMatrixQaCliOutput(output.stdout.trim())}\nstderr:\n${redactMatrixQaCliOutput(output.stderr.trim())}`,
|
||||
);
|
||||
},
|
||||
writeStdin: async (text) => {
|
||||
if (!child.stdin.write(text)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
child.stdin.once("drain", resolve);
|
||||
});
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
if (!closed) {
|
||||
clearTimeout(timeout);
|
||||
killRequested = true;
|
||||
killMatrixQaCliChild(child, "SIGTERM");
|
||||
scheduleForcedCleanup();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMatrixQaOpenClawCli(params: {
|
||||
allowNonZero?: boolean;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixQaCliRunResult> {
|
||||
return await startMatrixQaOpenClawCli(params).wait();
|
||||
}
|
||||
|
||||
async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const mode = (await stat(pathToCheck)).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMatrixQaOpenClawCliRuntime(params: {
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
artifactLabel: string;
|
||||
baseUrl: string;
|
||||
deviceId: string;
|
||||
displayName: string;
|
||||
outputDir: string;
|
||||
runtimeEnv: NodeJS.ProcessEnv;
|
||||
userId: string;
|
||||
}) {
|
||||
const rootDir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-cli-qa-"),
|
||||
);
|
||||
const artifactDir = path.join(
|
||||
params.outputDir,
|
||||
params.artifactLabel.replace(/[^A-Za-z0-9_-]/g, "-"),
|
||||
randomUUID().replaceAll("-", "").slice(0, 12),
|
||||
);
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const configPath = path.join(rootDir, "config.json");
|
||||
await chmod(rootDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory");
|
||||
await mkdir(artifactDir, { mode: 0o700, recursive: true });
|
||||
await chmod(artifactDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory");
|
||||
await mkdir(stateDir, { mode: 0o700, recursive: true });
|
||||
await chmod(stateDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
allow: ["matrix"],
|
||||
entries: {
|
||||
matrix: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: params.accountId,
|
||||
accounts: {
|
||||
[params.accountId]: {
|
||||
accessToken: params.accessToken,
|
||||
deviceId: params.deviceId,
|
||||
encryption: true,
|
||||
homeserver: params.baseUrl,
|
||||
initialSyncLimit: 0,
|
||||
name: params.displayName,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
startupVerification: "off",
|
||||
userId: params.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file");
|
||||
const env = {
|
||||
...params.runtimeEnv,
|
||||
FORCE_COLOR: "0",
|
||||
NO_COLOR: "1",
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
return {
|
||||
artifactDir,
|
||||
configPath,
|
||||
dispose: async () => {
|
||||
await rm(rootDir, { force: true, recursive: true });
|
||||
},
|
||||
run: async (
|
||||
args: string[],
|
||||
opts: { allowNonZero?: boolean; stdin?: string; timeoutMs: number },
|
||||
): Promise<MatrixQaCliRunResult> =>
|
||||
await runMatrixQaOpenClawCli({
|
||||
allowNonZero: opts.allowNonZero,
|
||||
args,
|
||||
env,
|
||||
stdin: opts.stdin,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
start: (args: string[], opts: { allowNonZero?: boolean; timeoutMs: number }) =>
|
||||
startMatrixQaOpenClawCli({
|
||||
allowNonZero: opts.allowNonZero,
|
||||
args,
|
||||
env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
killMatrixQaCliChild,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
// Qa Matrix helper module supports scenario runtime config behavior.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { isRecord as isMatrixQaPlainRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export { isMatrixQaPlainRecord };
|
||||
|
||||
function requireMatrixQaGatewayConfigObject(config: unknown): Record<string, unknown> {
|
||||
if (!isMatrixQaPlainRecord(config)) {
|
||||
throw new Error("Matrix QA gateway config file must contain an object");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readMatrixQaGatewayConfigFile(configPath: string) {
|
||||
return requireMatrixQaGatewayConfigObject(
|
||||
JSON.parse(await readFile(configPath, "utf8")) as unknown,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeMatrixQaGatewayConfigFile(configPath: string, config: unknown) {
|
||||
await replaceFileAtomic({
|
||||
filePath: configPath,
|
||||
content: `${JSON.stringify(config, null, 2)}\n`,
|
||||
mode: 0o600,
|
||||
tempPrefix: ".matrix-qa-config",
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMatrixQaGatewayMatrixAccount(params: {
|
||||
accountId: string;
|
||||
configPath: string;
|
||||
}) {
|
||||
const config = await readMatrixQaGatewayConfigFile(params.configPath);
|
||||
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
|
||||
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
|
||||
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
|
||||
const account = accounts[params.accountId];
|
||||
if (!isMatrixQaPlainRecord(account)) {
|
||||
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function replaceMatrixQaGatewayMatrixAccount(params: {
|
||||
accountConfig: Record<string, unknown>;
|
||||
accountId: string;
|
||||
configPath: string;
|
||||
}) {
|
||||
const config = await readMatrixQaGatewayConfigFile(params.configPath);
|
||||
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
|
||||
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
|
||||
channels.matrix = {
|
||||
...matrix,
|
||||
defaultAccount: params.accountId,
|
||||
accounts: {
|
||||
[params.accountId]: params.accountConfig,
|
||||
},
|
||||
};
|
||||
config.channels = channels;
|
||||
await writeMatrixQaGatewayConfigFile(params.configPath, config);
|
||||
}
|
||||
|
||||
export async function patchMatrixQaGatewayMatrixAccount(params: {
|
||||
accountId: string;
|
||||
accountPatch: Record<string, unknown>;
|
||||
configPath: string;
|
||||
}) {
|
||||
const config = await readMatrixQaGatewayConfigFile(params.configPath);
|
||||
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
|
||||
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
|
||||
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
|
||||
const existing = accounts[params.accountId];
|
||||
if (!isMatrixQaPlainRecord(existing)) {
|
||||
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
|
||||
}
|
||||
channels.matrix = {
|
||||
...matrix,
|
||||
defaultAccount: params.accountId,
|
||||
accounts: {
|
||||
[params.accountId]: {
|
||||
...existing,
|
||||
...params.accountPatch,
|
||||
},
|
||||
},
|
||||
};
|
||||
config.channels = channels;
|
||||
await writeMatrixQaGatewayConfigFile(params.configPath, config);
|
||||
}
|
||||
226
extensions/qa-matrix/src/runners/contract/scenario-runtime-dm.ts
Normal file
226
extensions/qa-matrix/src/runners/contract/scenario-runtime-dm.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// Qa Matrix plugin module implements scenario runtime dm behavior.
|
||||
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,
|
||||
advanceMatrixQaActorCursor,
|
||||
resolveMatrixQaNoReplyWindowMs,
|
||||
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: resolveMatrixQaNoReplyWindowMs(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;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { testing } from "./scenario-runtime-e2ee-destructive.js";
|
||||
|
||||
const storageMetadataRuntime = {
|
||||
normalizeMatrixStorageMetadata(value: unknown) {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const metadata = value as { deviceId?: unknown; userId?: unknown };
|
||||
return {
|
||||
...(typeof metadata.deviceId === "string" ? { deviceId: metadata.deviceId } : {}),
|
||||
...(typeof metadata.userId === "string" ? { userId: metadata.userId } : {}),
|
||||
};
|
||||
},
|
||||
openMatrixStorageMetaStoreOptions(storageRootDir: string) {
|
||||
return {
|
||||
namespace: "storage-meta",
|
||||
maxEntries: 10,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: storageRootDir },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
describe("Matrix destructive E2EE storage discovery", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
it("finds account metadata stored in account-local SQLite", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-storage-"));
|
||||
tempDirs.push(stateDir);
|
||||
const accountRoot = path.join(stateDir, "matrix", "accounts", "stored-key", "server", "token");
|
||||
createPluginStateSyncKeyedStoreForTests(
|
||||
"matrix",
|
||||
storageMetadataRuntime.openMatrixStorageMetaStoreOptions(accountRoot),
|
||||
).register("current", {
|
||||
deviceId: "DEVICE",
|
||||
userId: "@owner:matrix-qa.test",
|
||||
});
|
||||
resetPluginStateStoreForTests();
|
||||
|
||||
await expect(
|
||||
testing.findMatrixQaCliAccountRoot({
|
||||
deviceId: "DEVICE",
|
||||
runtime: { stateDir },
|
||||
storageMetadataRuntime,
|
||||
userId: "@owner:matrix-qa.test",
|
||||
}),
|
||||
).resolves.toBe(accountRoot);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
3668
extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts
Normal file
3668
extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
// Qa Matrix plugin module implements scenario runtime edit behavior.
|
||||
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,463 @@
|
||||
// Qa Matrix plugin module implements scenario runtime media behavior.
|
||||
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
|
||||
import { MATRIX_QA_MEDIA_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
|
||||
import {
|
||||
buildMatrixQaImageGenerationPrompt,
|
||||
buildMatrixQaImageUnderstandingPrompt,
|
||||
createMatrixQaVoicePreflightWav,
|
||||
createMatrixQaSplitColorImagePng,
|
||||
hasMatrixQaExpectedColorReply,
|
||||
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
|
||||
MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES,
|
||||
MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
|
||||
MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER,
|
||||
} 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}`;
|
||||
}
|
||||
|
||||
function normalizeMatrixQaVoiceReply(value: string | undefined) {
|
||||
return (value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasMatrixQaVoicePreflightReply(body: string | undefined) {
|
||||
return normalizeMatrixQaVoiceReply(body).includes(MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER);
|
||||
}
|
||||
|
||||
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 runVoicePreflightMentionScenario(context: MatrixQaScenarioContext) {
|
||||
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
|
||||
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
|
||||
const driverEventId = await client.sendMediaMessage({
|
||||
buffer: createMatrixQaVoicePreflightWav(),
|
||||
contentType: "audio/wav",
|
||||
fileName: MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
|
||||
kind: "audio",
|
||||
roomId,
|
||||
});
|
||||
const attachmentEvent = await client.waitForRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.roomId === roomId &&
|
||||
event.eventId === driverEventId &&
|
||||
event.sender === context.driverUserId &&
|
||||
event.msgtype === "m.audio" &&
|
||||
event.attachment?.kind === "audio" &&
|
||||
event.attachment.filename === MATRIX_QA_VOICE_PREFLIGHT_FILENAME &&
|
||||
event.attachment.caption === undefined,
|
||||
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) &&
|
||||
hasMatrixQaVoicePreflightReply(event.body),
|
||||
roomId,
|
||||
since: attachmentEvent.since,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: context.syncState,
|
||||
nextSince: matched.since,
|
||||
startSince,
|
||||
});
|
||||
const reply = buildMatrixReplyArtifact(matched.event, MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER);
|
||||
return {
|
||||
artifacts: {
|
||||
attachmentFilename: MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
|
||||
driverEventId,
|
||||
reply,
|
||||
roomId,
|
||||
expectedMarker: MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER,
|
||||
},
|
||||
details: [
|
||||
`room id: ${roomId}`,
|
||||
`driver voice event: ${driverEventId}`,
|
||||
`voice filename: ${MATRIX_QA_VOICE_PREFLIGHT_FILENAME}`,
|
||||
...buildMatrixReplyDetails("reply", reply),
|
||||
].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 driverEventIds: string[] = [];
|
||||
const isGeneratedImageEvent = (event: MatrixQaObservedEvent) =>
|
||||
event.roomId === roomId &&
|
||||
event.sender === context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.relatesTo === undefined &&
|
||||
event.msgtype === "m.image" &&
|
||||
event.attachment?.kind === "image";
|
||||
let matched = await client.waitForOptionalRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: isGeneratedImageEvent,
|
||||
roomId,
|
||||
since: startSince,
|
||||
timeoutMs: 0,
|
||||
});
|
||||
for (let attempt = 1; !matched.matched && attempt <= 2; attempt += 1) {
|
||||
const driverEventId = await client.sendTextMessage({
|
||||
body: triggerBody,
|
||||
mentionUserIds: [context.sutUserId],
|
||||
roomId,
|
||||
});
|
||||
driverEventIds.push(driverEventId);
|
||||
matched = await client.waitForOptionalRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: isGeneratedImageEvent,
|
||||
roomId,
|
||||
since: matched.since ?? startSince,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
}
|
||||
if (!matched.matched) {
|
||||
throw new Error(
|
||||
`timed out after ${context.timeoutMs}ms waiting for Matrix generated image after ${driverEventIds.length} attempt(s)`,
|
||||
);
|
||||
}
|
||||
const matchedEvent = matched.event;
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: context.syncState,
|
||||
nextSince: matched.since,
|
||||
startSince,
|
||||
});
|
||||
const attachment = requireMatrixQaImageAttachment(
|
||||
matchedEvent,
|
||||
"Matrix generated image delivery scenario",
|
||||
);
|
||||
return {
|
||||
artifacts: {
|
||||
attachmentBodyPreview: matchedEvent.body?.slice(0, 200),
|
||||
attachmentEventId: matchedEvent.eventId,
|
||||
attachmentFilename: attachment.filename,
|
||||
attachmentKind: attachment.kind,
|
||||
attachmentMsgtype: matchedEvent.msgtype,
|
||||
driverEventId: driverEventIds[0],
|
||||
driverEventIds,
|
||||
roomId,
|
||||
triggerBody,
|
||||
},
|
||||
details: [
|
||||
`room id: ${roomId}`,
|
||||
`driver events: ${driverEventIds.join(", ")}`,
|
||||
...buildMatrixQaAttachmentDetailLines({
|
||||
attachmentEvent: matchedEvent,
|
||||
label: "generated image",
|
||||
}),
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Qa Matrix plugin module implements scenario runtime reaction behavior.
|
||||
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,429 @@
|
||||
// Qa Matrix plugin module implements scenario runtime restart behavior.
|
||||
import {
|
||||
MATRIX_QA_HOMESERVER_ROOM_KEY,
|
||||
MATRIX_QA_RESTART_ROOM_KEY,
|
||||
MATRIX_QA_STALE_SYNC_ROOM_KEY,
|
||||
resolveMatrixQaScenarioRoomId,
|
||||
} from "./scenario-catalog.js";
|
||||
import {
|
||||
buildMatrixReplyDetails,
|
||||
buildMatrixQaToken,
|
||||
buildMentionPrompt,
|
||||
buildMatrixReplyArtifact,
|
||||
isMatrixQaExactMarkerReply,
|
||||
assertTopLevelReplyArtifact,
|
||||
advanceMatrixQaActorCursor,
|
||||
primeMatrixQaDriverScenarioClient,
|
||||
resolveMatrixQaNoReplyWindowMs,
|
||||
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";
|
||||
|
||||
type MatrixQaDriverClient = Awaited<ReturnType<typeof primeMatrixQaDriverScenarioClient>>["client"];
|
||||
type MatrixReplyArtifact = ReturnType<typeof buildMatrixReplyArtifact>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function sendAndAssertRestartReplayReply(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
replyLabel: string;
|
||||
roomId: string;
|
||||
tokenPrefix: string;
|
||||
}) {
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(params.context);
|
||||
const replayToken = buildMatrixQaToken(params.tokenPrefix);
|
||||
const replayBody = buildMentionPrompt(params.context.sutUserId, replayToken);
|
||||
const replayDriverEventId = await client.sendTextMessage({
|
||||
body: replayBody,
|
||||
mentionUserIds: [params.context.sutUserId],
|
||||
roomId: params.roomId,
|
||||
});
|
||||
const firstMatched = await client.waitForRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) =>
|
||||
isMatrixQaExactMarkerReply(event, {
|
||||
roomId: params.roomId,
|
||||
sutUserId: params.context.sutUserId,
|
||||
token: replayToken,
|
||||
}) && event.relatesTo === undefined,
|
||||
roomId: params.roomId,
|
||||
since: startSince,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: params.context.syncState,
|
||||
nextSince: firstMatched.since,
|
||||
startSince,
|
||||
});
|
||||
const firstReply = buildMatrixReplyArtifact(firstMatched.event, replayToken);
|
||||
assertTopLevelReplyArtifact(params.replyLabel, firstReply);
|
||||
|
||||
return { client, firstMatched, firstReply, replayDriverEventId, replayToken, startSince };
|
||||
}
|
||||
|
||||
async function assertNoRestartReplayDuplicate(params: {
|
||||
client: MatrixQaDriverClient;
|
||||
context: MatrixQaScenarioContext;
|
||||
errorDetails: string[];
|
||||
errorTitle: string;
|
||||
firstMatchedSince: string | undefined;
|
||||
firstReply: MatrixReplyArtifact;
|
||||
replayToken: string;
|
||||
roomId: string;
|
||||
startSince: string;
|
||||
}) {
|
||||
const duplicate = await params.client.waitForOptionalRoomEvent({
|
||||
observedEvents: params.context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.eventId !== params.firstReply.eventId &&
|
||||
isMatrixQaExactMarkerReply(event, {
|
||||
roomId: params.roomId,
|
||||
sutUserId: params.context.sutUserId,
|
||||
token: params.replayToken,
|
||||
}),
|
||||
roomId: params.roomId,
|
||||
timeoutMs: resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs),
|
||||
});
|
||||
if (duplicate.matched) {
|
||||
throw new Error(
|
||||
[
|
||||
params.errorTitle,
|
||||
...params.errorDetails,
|
||||
...buildMatrixReplyDetails("first reply", params.firstReply),
|
||||
...buildMatrixReplyDetails(
|
||||
"duplicate reply",
|
||||
buildMatrixReplyArtifact(duplicate.event, params.replayToken),
|
||||
),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: params.context.syncState,
|
||||
nextSince: duplicate.since,
|
||||
startSince: params.firstMatchedSince ?? params.startSince,
|
||||
});
|
||||
}
|
||||
|
||||
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, firstMatched, firstReply, replayDriverEventId, replayToken, startSince } =
|
||||
await sendAndAssertRestartReplayReply({
|
||||
context,
|
||||
replyLabel: "first replay-dedupe reply",
|
||||
roomId,
|
||||
tokenPrefix: "MATRIX_QA_REPLAY_DEDUPE",
|
||||
});
|
||||
|
||||
await context.restartGateway();
|
||||
|
||||
await assertNoRestartReplayDuplicate({
|
||||
client,
|
||||
context,
|
||||
errorDetails: [`original driver event: ${replayDriverEventId}`],
|
||||
errorTitle: "Matrix restart replayed an already handled event",
|
||||
firstMatchedSince: firstMatched.since,
|
||||
firstReply,
|
||||
replayToken,
|
||||
roomId,
|
||||
startSince,
|
||||
});
|
||||
|
||||
const postRestart = await runAssertedDriverTopLevelScenario({
|
||||
context,
|
||||
label: "fresh post-restart reply",
|
||||
roomId,
|
||||
tokenPrefix: "MATRIX_QA_REPLAY_DEDUPE_FRESH",
|
||||
});
|
||||
|
||||
return {
|
||||
artifacts: {
|
||||
duplicateWindowMs: resolveMatrixQaNoReplyWindowMs(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: ${resolveMatrixQaNoReplyWindowMs(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_STALE_SYNC_ROOM_KEY);
|
||||
const syncStore = await waitForMatrixSyncStoreWithCursor({
|
||||
context,
|
||||
stateDir,
|
||||
timeoutMs: Math.min(5_000, context.timeoutMs),
|
||||
});
|
||||
const staleCursor = syncStore.cursor;
|
||||
|
||||
const { client, firstMatched, firstReply, replayDriverEventId, replayToken, startSince } =
|
||||
await sendAndAssertRestartReplayReply({
|
||||
context,
|
||||
replyLabel: "first stale-sync replay-dedupe reply",
|
||||
roomId,
|
||||
tokenPrefix: "MATRIX_QA_STALE_SYNC_DEDUPE",
|
||||
});
|
||||
|
||||
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,
|
||||
source: syncStore.source,
|
||||
stateKey: syncStore.stateKey,
|
||||
});
|
||||
});
|
||||
|
||||
await assertNoRestartReplayDuplicate({
|
||||
client,
|
||||
context,
|
||||
errorDetails: [
|
||||
`original driver event: ${replayDriverEventId}`,
|
||||
`stale sync cursor: ${staleCursor}`,
|
||||
],
|
||||
errorTitle: "Matrix stale sync cursor replayed an already handled event",
|
||||
firstMatchedSince: firstMatched.since,
|
||||
firstReply,
|
||||
replayToken,
|
||||
roomId,
|
||||
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: resolveMatrixQaNoReplyWindowMs(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: ${resolveMatrixQaNoReplyWindowMs(context.timeoutMs)}ms`,
|
||||
`fresh post-restart driver event: ${postRestart.driverEventId}`,
|
||||
...buildMatrixReplyDetails("fresh reply", postRestart.reply),
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
1580
extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts
Normal file
1580
extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
// Qa Matrix tests cover scenario runtime shared plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixQaNoReplyWindowMs } from "./scenario-runtime-shared.js";
|
||||
|
||||
describe("matrix scenario runtime shared", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("normalizes the Matrix QA no-reply window env", () => {
|
||||
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(8_000);
|
||||
|
||||
vi.stubEnv("OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS", "12000");
|
||||
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(12_000);
|
||||
expect(resolveMatrixQaNoReplyWindowMs(5_000)).toBe(5_000);
|
||||
|
||||
for (const value of ["1e3", "0x1000", "1.5", "nope"]) {
|
||||
vi.stubEnv("OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS", value);
|
||||
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(8_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
// Qa Matrix plugin module implements scenario runtime shared behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createMatrixQaClient, type MatrixQaRoomObserver } from "../../substrate/client.js";
|
||||
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
|
||||
import type { MatrixQaFaultProxyObserver } from "../../substrate/fault-proxy.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;
|
||||
faultProxyObserver?: MatrixQaFaultProxyObserver;
|
||||
faultProxyTargetBaseUrl?: string;
|
||||
observedEvents: MatrixQaObservedEvent[];
|
||||
observerAccessToken: string;
|
||||
observerDeviceId?: string;
|
||||
observerPassword?: string;
|
||||
observerUserId: string;
|
||||
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
|
||||
gatewayStateDir?: string;
|
||||
gatewayWorkspaceDir?: string;
|
||||
gatewayCall?: (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: { expectFinal?: boolean; timeoutMs?: number },
|
||||
) => Promise<unknown>;
|
||||
outputDir?: string;
|
||||
registrationToken?: string;
|
||||
restartGateway?: () => Promise<void>;
|
||||
restartGatewayAfterStateMutation?: (
|
||||
mutateState: (context: { stateDir: string }) => Promise<void>,
|
||||
opts?: { timeoutMs?: number; waitAccountId?: string },
|
||||
) => 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?: { replacePaths?: string[]; restartDelayMs?: number },
|
||||
) => Promise<void>;
|
||||
waitGatewayAccountReady?: (accountId: string, opts?: { timeoutMs?: number }) => Promise<void>;
|
||||
};
|
||||
|
||||
const NO_REPLY_WINDOW_MS = 8_000;
|
||||
const NO_REPLY_WINDOW_ENV = "OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS";
|
||||
|
||||
export function resolveMatrixQaNoReplyWindowMs(timeoutMs: number) {
|
||||
const raw = process.env[NO_REPLY_WINDOW_ENV]?.trim();
|
||||
const parsed =
|
||||
raw === undefined ? NO_REPLY_WINDOW_MS : /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
||||
const windowMs = Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : NO_REPLY_WINDOW_MS;
|
||||
return Math.min(windowMs, timeoutMs);
|
||||
}
|
||||
|
||||
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 buildMatrixPartialStreamingPrompt(sutUserId: string, text: string) {
|
||||
return `${sutUserId} Partial streaming QA check: reply exactly \`${text}\`.`;
|
||||
}
|
||||
|
||||
export const MATRIX_QA_TOOL_PROGRESS_TASK_FILENAME = "QA_KICKOFF_TASK.md";
|
||||
export const MATRIX_QA_TOOL_PROGRESS_MENTION_FILENAME =
|
||||
"matrix-progress-@room-@alice:matrix-qa.test-!room:matrix-qa.test.txt";
|
||||
export const MATRIX_QA_TOOL_PROGRESS_COMMAND = "printf 'matrix-command-progress-start\\n'; sleep 2";
|
||||
|
||||
export function buildMatrixToolProgressTaskContent(text: string) {
|
||||
return [
|
||||
"Matrix tool progress QA task.",
|
||||
"Reply with only this exact marker and no other text:",
|
||||
text,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildMatrixToolProgressPrompt(sutUserId: string) {
|
||||
return [
|
||||
`${sutUserId} Tool progress QA check: call the read tool exactly once on \`${MATRIX_QA_TOOL_PROGRESS_TASK_FILENAME}\` before answering.`,
|
||||
`The QA harness must observe that read tool call; the only valid final marker is inside that file.`,
|
||||
`Do not guess or send any marker before the tool result returns.`,
|
||||
`Do not read \`HEARTBEAT.md\` for this check.`,
|
||||
`After that read completes, reply with only the exact marker from the file and no other text.`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildMatrixToolProgressCommandPrompt(sutUserId: string, text: string) {
|
||||
return [
|
||||
`${sutUserId} Tool progress QA check: call the exec tool exactly once with this exact command before answering: \`${MATRIX_QA_TOOL_PROGRESS_COMMAND}\`.`,
|
||||
`The QA harness must observe that exec command preview and its completion as edits to one Matrix draft.`,
|
||||
`After that exec command completes or fails, reply exactly \`${text}\`.`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildMatrixToolProgressErrorPrompt(sutUserId: string, text: string) {
|
||||
return [
|
||||
`${sutUserId} Tool progress error QA check: read \`missing-matrix-tool-progress-target.txt\` before answering.`,
|
||||
`After the read fails, reply exactly \`${text}\`.`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildMatrixToolProgressMentionSafetyPrompt(sutUserId: string, text: string) {
|
||||
return [
|
||||
`${sutUserId} Tool progress QA check: read the missing workspace file \`${MATRIX_QA_TOOL_PROGRESS_MENTION_FILENAME}\` before answering.`,
|
||||
`The QA harness must observe that failed read in a Matrix tool-progress preview.`,
|
||||
`Do not guess or send any marker before the tool result returns.`,
|
||||
`After that read fails, reply exactly \`${text}\`.`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildMatrixBlockStreamingPrompt(
|
||||
sutUserId: string,
|
||||
firstText: string,
|
||||
secondText: string,
|
||||
) {
|
||||
return [
|
||||
`${sutUserId} Block streaming QA check: complete this whole sequence in one turn.`,
|
||||
`Step 1: send an assistant text block containing only this exact marker: \`${firstText}\`.`,
|
||||
"That first marker block must be emitted before any tool call.",
|
||||
"Step 2: after the first marker block, use the read tool exactly once on `QA_KICKOFF_TASK.md`.",
|
||||
`Step 3: after that read completes, send a final assistant text block containing only this exact marker: \`${secondText}\`.`,
|
||||
"Never put both markers in the same assistant text block.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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 = resolveMatrixQaNoReplyWindowMs(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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
sendClient?: MatrixQaScenarioClient;
|
||||
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 sendClient = params.sendClient ?? client;
|
||||
const triggerEventId = await sendClient.sendTextMessage({
|
||||
body: params.body,
|
||||
...(params.mentionUserIds ? { mentionUserIds: params.mentionUserIds } : {}),
|
||||
roomId: params.roomId,
|
||||
});
|
||||
let observedTriggerEvent = false;
|
||||
const result = await client.waitForOptionalRoomEvent({
|
||||
observedEvents: params.observedEvents,
|
||||
predicate: (event) => {
|
||||
if (event.roomId !== params.roomId) {
|
||||
return false;
|
||||
}
|
||||
if (event.eventId === triggerEventId) {
|
||||
observedTriggerEvent = true;
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
observedTriggerEvent &&
|
||||
event.sender === params.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
(params.replyPredicate?.(event, { driverEventId: triggerEventId, 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: triggerEventId,
|
||||
expectedNoReplyWindowMs: params.timeoutMs,
|
||||
token: params.token,
|
||||
triggerBody: params.body,
|
||||
},
|
||||
details: [
|
||||
`trigger event: ${triggerEventId}`,
|
||||
`trigger sender: ${params.actorUserId}`,
|
||||
`waited ${params.timeoutMs}ms with no SUT reply`,
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
|
||||
import { waitForMatrixInboundDedupeEntry } from "./scenario-runtime-state-files.js";
|
||||
|
||||
const dedupeStoreRuntime = {
|
||||
openMatrixInboundDedupeStoreOptions(params: { stateDir?: string }) {
|
||||
return {
|
||||
namespace: "inbound-dedupe",
|
||||
maxEntries: 20_000,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function buildDedupeKey(params: { accountId: string; eventId: string; roomId: string }) {
|
||||
return `${params.accountId}:${createHash("sha256")
|
||||
.update(params.accountId)
|
||||
.update("\0")
|
||||
.update(params.roomId)
|
||||
.update("\0")
|
||||
.update(params.eventId)
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
describe("Matrix QA persisted state probes", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
it("observes inbound dedupe entries through the canonical plugin-state store", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-dedupe-"));
|
||||
tempDirs.push(stateDir);
|
||||
const accountRoot = path.join(stateDir, "matrix", "accounts", "sut", "server", "token");
|
||||
const accountId = "sut";
|
||||
const eventId = "$event";
|
||||
const roomId = "!room:matrix-qa.test";
|
||||
const options = dedupeStoreRuntime.openMatrixInboundDedupeStoreOptions({
|
||||
stateDir: accountRoot,
|
||||
});
|
||||
const runtimeAccountId = "runtime-default";
|
||||
createPluginStateSyncKeyedStoreForTests("matrix", options).register(
|
||||
buildDedupeKey({ accountId: runtimeAccountId, eventId, roomId }),
|
||||
{ eventId, roomId, ts: Date.now() },
|
||||
);
|
||||
resetPluginStateStoreForTests();
|
||||
|
||||
await expect(
|
||||
waitForMatrixInboundDedupeEntry({
|
||||
context: { sutAccountId: accountId } as MatrixQaScenarioContext,
|
||||
dedupeStoreRuntime,
|
||||
eventId,
|
||||
roomId,
|
||||
stateDir,
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
).resolves.toBe(path.join(accountRoot, "state", "openclaw.sqlite"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
// Qa Matrix plugin module implements scenario runtime state files behavior.
|
||||
import { createHash, 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 { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { loadMatrixQaE2eeRuntime } from "../../substrate/e2ee-client.js";
|
||||
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_PLUGIN_ID = "matrix";
|
||||
const MATRIX_SYNC_CACHE_NAMESPACE = "sync-cache";
|
||||
const MATRIX_STATE_POLL_INTERVAL_MS = 100;
|
||||
const MATRIX_SYNC_CACHE_MAX_ENTRIES = 20_000;
|
||||
const MATRIX_SYNC_CACHE_MAX_CHUNKS = Math.floor((MATRIX_SYNC_CACHE_MAX_ENTRIES - 1) / 2);
|
||||
// PluginState serializes this string inside a row object; 24KB leaves room for JSON escaping.
|
||||
const MATRIX_SYNC_CACHE_CHUNK_BYTES = 24_000;
|
||||
|
||||
type MatrixQaInboundDedupeStoreRuntime = {
|
||||
openMatrixInboundDedupeStoreOptions: (params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}) => OpenKeyedStoreOptions;
|
||||
};
|
||||
|
||||
type MatrixSyncStoreCursor = {
|
||||
cursor: string;
|
||||
pathname: string;
|
||||
source: "json" | "sqlite";
|
||||
stateKey?: string;
|
||||
};
|
||||
|
||||
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.nextBatch === "string") {
|
||||
return {
|
||||
...parsed,
|
||||
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));
|
||||
}
|
||||
|
||||
function parsePluginStateJson(raw: unknown): unknown {
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readMatrixSyncCacheCursorFromRows(
|
||||
rows: Array<{ entryKey?: unknown; valueJson?: unknown }>,
|
||||
): MatrixSyncStoreCursor[] {
|
||||
const rowsByKey = new Map<string, unknown>();
|
||||
for (const row of rows) {
|
||||
if (typeof row.entryKey === "string") {
|
||||
rowsByKey.set(row.entryKey, parsePluginStateJson(row.valueJson));
|
||||
}
|
||||
}
|
||||
const cursors: MatrixSyncStoreCursor[] = [];
|
||||
for (const [entryKey, rawMeta] of rowsByKey) {
|
||||
if (!entryKey.endsWith(":meta") || !isRecord(rawMeta) || rawMeta.kind !== "meta") {
|
||||
continue;
|
||||
}
|
||||
const stateKey = entryKey.slice(0, -":meta".length);
|
||||
const generation = typeof rawMeta.generation === "string" ? rawMeta.generation : "";
|
||||
const chunkCount =
|
||||
typeof rawMeta.chunkCount === "number" &&
|
||||
Number.isSafeInteger(rawMeta.chunkCount) &&
|
||||
rawMeta.chunkCount <= MATRIX_SYNC_CACHE_MAX_CHUNKS
|
||||
? rawMeta.chunkCount
|
||||
: 0;
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunk = rowsByKey.get(`${stateKey}:sync:${generation}:${index}`);
|
||||
if (!isRecord(chunk) || typeof chunk.data !== "string") {
|
||||
chunks.length = 0;
|
||||
break;
|
||||
}
|
||||
chunks.push(chunk.data);
|
||||
}
|
||||
if (chunks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const cursor = readPersistedMatrixSyncCursor({
|
||||
savedSync: JSON.parse(chunks.join("")) as unknown,
|
||||
});
|
||||
if (cursor) {
|
||||
cursors.push({ cursor, pathname: "", source: "sqlite", stateKey });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return cursors;
|
||||
}
|
||||
|
||||
async function readMatrixSyncCacheCursorsFromSqlite(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
stateDir: string;
|
||||
userId?: string;
|
||||
}): Promise<MatrixSyncStoreCursor[]> {
|
||||
const databasePaths = await findFilesByName({
|
||||
filename: "openclaw.sqlite",
|
||||
rootDir: params.stateDir,
|
||||
maxDepth: 10,
|
||||
});
|
||||
const cursors: Array<MatrixSyncStoreCursor & { score: number }> = [];
|
||||
try {
|
||||
const sqlite = await import("node:sqlite");
|
||||
for (const databasePath of databasePaths) {
|
||||
try {
|
||||
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT entry_key AS entryKey, value_json AS valueJson
|
||||
FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
)
|
||||
.all(MATRIX_PLUGIN_ID, MATRIX_SYNC_CACHE_NAMESPACE, Date.now()) as Array<{
|
||||
entryKey?: unknown;
|
||||
valueJson?: unknown;
|
||||
}>;
|
||||
for (const cursor of readMatrixSyncCacheCursorFromRows(rows)) {
|
||||
const storageRootDir = path.dirname(path.dirname(databasePath));
|
||||
cursors.push({
|
||||
...cursor,
|
||||
pathname: databasePath,
|
||||
score: await scoreMatrixStateFile({
|
||||
context: params.context,
|
||||
pathname: path.join(storageRootDir, MATRIX_SYNC_STORE_FILENAME),
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return cursors
|
||||
.toSorted((a, b) => b.score - a.score || a.pathname.localeCompare(b.pathname))
|
||||
.map(({ score: _score, ...cursor }) => cursor);
|
||||
}
|
||||
|
||||
function chunkMatrixSyncCacheJson(value: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
let currentBytes = 0;
|
||||
for (const char of value) {
|
||||
const charBytes = Buffer.byteLength(char, "utf8");
|
||||
if (current && currentBytes + charBytes > MATRIX_SYNC_CACHE_CHUNK_BYTES) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += char;
|
||||
currentBytes += charBytes;
|
||||
}
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function digestText(value: string): string {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function rewriteMatrixSyncCacheRows(params: {
|
||||
cursor: string;
|
||||
pathname: string;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const sqlite = await import("node:sqlite");
|
||||
const db = new sqlite.DatabaseSync(params.pathname);
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT entry_key AS entryKey, value_json AS valueJson
|
||||
FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND entry_key LIKE ?`,
|
||||
)
|
||||
.all(MATRIX_PLUGIN_ID, MATRIX_SYNC_CACHE_NAMESPACE, `${params.stateKey}:%`) as Array<{
|
||||
entryKey?: unknown;
|
||||
valueJson?: unknown;
|
||||
}>;
|
||||
const meta = parsePluginStateJson(
|
||||
rows.find((row) => row.entryKey === `${params.stateKey}:meta`)?.valueJson,
|
||||
);
|
||||
if (!isRecord(meta)) {
|
||||
throw new Error("Matrix sync cache metadata row was missing");
|
||||
}
|
||||
const cursorEntry = readMatrixSyncCacheCursorFromRows(rows)[0];
|
||||
if (!cursorEntry) {
|
||||
throw new Error("Matrix sync cache did not contain a persisted sync cursor");
|
||||
}
|
||||
const generation = typeof meta.generation === "string" ? meta.generation : "";
|
||||
const chunkCount =
|
||||
typeof meta.chunkCount === "number" &&
|
||||
Number.isSafeInteger(meta.chunkCount) &&
|
||||
meta.chunkCount <= MATRIX_SYNC_CACHE_MAX_CHUNKS
|
||||
? meta.chunkCount
|
||||
: 0;
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
const chunk = parsePluginStateJson(
|
||||
rows.find((row) => row.entryKey === `${params.stateKey}:sync:${generation}:${index}`)
|
||||
?.valueJson,
|
||||
);
|
||||
if (!isRecord(chunk) || typeof chunk.data !== "string") {
|
||||
throw new Error("Matrix sync cache chunk row was missing");
|
||||
}
|
||||
chunks.push(chunk.data);
|
||||
}
|
||||
const syncJson = JSON.stringify(
|
||||
writePersistedMatrixSyncCursor(JSON.parse(chunks.join("")), params.cursor),
|
||||
);
|
||||
const nextGeneration = randomUUID().replaceAll("-", "");
|
||||
const nextChunks = chunkMatrixSyncCacheJson(syncJson);
|
||||
const now = Date.now();
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO plugin_state_entries (plugin_id, namespace, entry_key, value_json, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(plugin_id, namespace, entry_key)
|
||||
DO UPDATE SET value_json = excluded.value_json, created_at = excluded.created_at, expires_at = NULL`,
|
||||
);
|
||||
for (const [index, data] of nextChunks.entries()) {
|
||||
upsert.run(
|
||||
MATRIX_PLUGIN_ID,
|
||||
MATRIX_SYNC_CACHE_NAMESPACE,
|
||||
`${params.stateKey}:sync:${nextGeneration}:${index}`,
|
||||
JSON.stringify({ kind: "sync-chunk", index, data }),
|
||||
now,
|
||||
);
|
||||
}
|
||||
upsert.run(
|
||||
MATRIX_PLUGIN_ID,
|
||||
MATRIX_SYNC_CACHE_NAMESPACE,
|
||||
`${params.stateKey}:meta`,
|
||||
JSON.stringify({
|
||||
...meta,
|
||||
generation: nextGeneration,
|
||||
chunkCount: nextChunks.length,
|
||||
syncDigest: digestText(syncJson),
|
||||
}),
|
||||
now,
|
||||
);
|
||||
db.prepare(
|
||||
`DELETE FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND entry_key LIKE ?
|
||||
AND entry_key NOT LIKE ?`,
|
||||
).run(
|
||||
MATRIX_PLUGIN_ID,
|
||||
MATRIX_SYNC_CACHE_NAMESPACE,
|
||||
`${params.stateKey}:sync:%`,
|
||||
`${params.stateKey}:sync:${nextGeneration}:%`,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewriteMatrixSyncStoreCursor(params: {
|
||||
cursor: string;
|
||||
pathname: string;
|
||||
source?: "json" | "sqlite";
|
||||
stateKey?: string;
|
||||
}) {
|
||||
if (params.source === "sqlite" || params.stateKey) {
|
||||
if (!params.stateKey) {
|
||||
throw new Error("Matrix sync cache rewrite requires a state key");
|
||||
}
|
||||
await rewriteMatrixSyncCacheRows({
|
||||
cursor: params.cursor,
|
||||
pathname: params.pathname,
|
||||
stateKey: params.stateKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parsed = await readJsonFile(params.pathname);
|
||||
await writeJsonFile(params.pathname, writePersistedMatrixSyncCursor(parsed, params.cursor));
|
||||
}
|
||||
|
||||
export async function deleteMatrixSyncStoreCursor(params: MatrixSyncStoreCursor) {
|
||||
if (params.source !== "sqlite" || !params.stateKey) {
|
||||
await fs.rm(params.pathname, { force: true });
|
||||
return;
|
||||
}
|
||||
const sqlite = await import("node:sqlite");
|
||||
const db = new sqlite.DatabaseSync(params.pathname);
|
||||
try {
|
||||
db.prepare(
|
||||
`DELETE FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND (entry_key = ? OR entry_key LIKE ?)`,
|
||||
).run(
|
||||
MATRIX_PLUGIN_ID,
|
||||
MATRIX_SYNC_CACHE_NAMESPACE,
|
||||
`${params.stateKey}:meta`,
|
||||
`${params.stateKey}:sync:%`,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function scoreMatrixStateFile(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
pathname: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
let score = params.pathname.includes(`${path.sep}matrix${path.sep}`) ? 4 : 0;
|
||||
const expectedUserId = params.userId ?? params.context.sutUserId;
|
||||
const expectedAccountId = params.accountId ?? params.context.sutAccountId;
|
||||
try {
|
||||
const metadata = await readJsonFile(
|
||||
path.join(path.dirname(params.pathname), "storage-meta.json"),
|
||||
);
|
||||
if (isRecord(metadata) && metadata.userId === expectedUserId) {
|
||||
score += 16;
|
||||
}
|
||||
if (isRecord(metadata) && metadata.accountId === expectedAccountId) {
|
||||
score += 8;
|
||||
}
|
||||
} catch {
|
||||
// Missing metadata is allowed; the Matrix client may not have flushed it yet.
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
async function resolveBestMatrixStateFile(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
filename: string;
|
||||
stateDir: string;
|
||||
userId?: 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,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
scored.sort((a, b) => b.score - a.score || a.pathname.localeCompare(b.pathname));
|
||||
return scored[0]?.pathname ?? null;
|
||||
}
|
||||
|
||||
export async function waitForMatrixSyncStoreWithCursor(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
stateDir: string;
|
||||
timeoutMs: number;
|
||||
userId?: string;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let lastPath: string | null = null;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const sqliteCursors = await readMatrixSyncCacheCursorsFromSqlite({
|
||||
context: params.context,
|
||||
stateDir: params.stateDir,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
});
|
||||
if (sqliteCursors.length > 0) {
|
||||
return sqliteCursors[0];
|
||||
}
|
||||
const pathname = await resolveBestMatrixStateFile({
|
||||
context: params.context,
|
||||
filename: MATRIX_SYNC_STORE_FILENAME,
|
||||
stateDir: params.stateDir,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
});
|
||||
lastPath = pathname;
|
||||
if (pathname) {
|
||||
const cursor = await readMatrixSyncStoreCursor(pathname);
|
||||
if (cursor) {
|
||||
return { cursor, pathname, source: "json" as const };
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function buildMatrixInboundDedupePluginStateKey(params: {
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
}): string {
|
||||
const accountId = params.accountId.trim() || "sut";
|
||||
const roomId = params.roomId.trim();
|
||||
const eventId = params.eventId.trim();
|
||||
const digest = createHash("sha256")
|
||||
.update(accountId)
|
||||
.update("\0")
|
||||
.update(roomId)
|
||||
.update("\0")
|
||||
.update(eventId)
|
||||
.digest("hex");
|
||||
return `${accountId}:${digest}`;
|
||||
}
|
||||
|
||||
async function hasPersistedMatrixPluginStateDedupeEntry(params: {
|
||||
accountId: string;
|
||||
dedupeStoreRuntime?: MatrixQaInboundDedupeStoreRuntime;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
stateDir: string;
|
||||
}): Promise<string | null> {
|
||||
const entryKey = buildMatrixInboundDedupePluginStateKey({
|
||||
accountId: params.accountId,
|
||||
eventId: params.eventId,
|
||||
roomId: params.roomId,
|
||||
});
|
||||
const dedupeStoreRuntime = params.dedupeStoreRuntime ?? (await loadMatrixQaE2eeRuntime());
|
||||
const databasePaths = await findFilesByName({
|
||||
filename: "openclaw.sqlite",
|
||||
rootDir: params.stateDir,
|
||||
maxDepth: 10,
|
||||
});
|
||||
let sqlite: typeof import("node:sqlite");
|
||||
try {
|
||||
sqlite = await import("node:sqlite");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const databasePath of databasePaths) {
|
||||
try {
|
||||
const storageRootDir = path.dirname(path.dirname(databasePath));
|
||||
const options = dedupeStoreRuntime.openMatrixInboundDedupeStoreOptions({
|
||||
stateDir: storageRootDir,
|
||||
});
|
||||
const stateRoot = options.env?.OPENCLAW_STATE_DIR?.trim();
|
||||
if (
|
||||
!stateRoot ||
|
||||
path.resolve(stateRoot, "state", "openclaw.sqlite") !== path.resolve(databasePath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT entry_key AS entryKey, value_json AS valueJson
|
||||
FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
)
|
||||
.all(MATRIX_PLUGIN_ID, options.namespace, Date.now()) as Array<{
|
||||
entryKey?: unknown;
|
||||
valueJson?: unknown;
|
||||
}>;
|
||||
const matched = rows.some((row) => {
|
||||
if (row.entryKey === entryKey) {
|
||||
return true;
|
||||
}
|
||||
const entry = parsePluginStateJson(row.valueJson);
|
||||
return (
|
||||
isRecord(entry) && entry.roomId === params.roomId && entry.eventId === params.eventId
|
||||
);
|
||||
});
|
||||
if (matched) {
|
||||
return databasePath;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForMatrixInboundDedupeEntry(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
dedupeStoreRuntime?: MatrixQaInboundDedupeStoreRuntime;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
stateDir: string;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const sqlitePath = await hasPersistedMatrixPluginStateDedupeEntry({
|
||||
accountId: params.context.sutAccountId ?? "sut",
|
||||
...(params.dedupeStoreRuntime ? { dedupeStoreRuntime: params.dedupeStoreRuntime } : {}),
|
||||
eventId: params.eventId,
|
||||
roomId: params.roomId,
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
if (sqlitePath) {
|
||||
return sqlitePath;
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
473
extensions/qa-matrix/src/runners/contract/scenario-runtime.ts
Normal file
473
extensions/qa-matrix/src/runners/contract/scenario-runtime.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
// Qa Matrix plugin module implements scenario runtime behavior.
|
||||
import {
|
||||
MATRIX_QA_DRIVER_DM_ROOM_KEY,
|
||||
MATRIX_QA_SECONDARY_ROOM_KEY,
|
||||
type MatrixQaScenarioDefinition,
|
||||
} from "./scenario-catalog.js";
|
||||
import {
|
||||
runAllowBotsDefaultBlockScenario,
|
||||
runAllowBotsMentionsDmUnmentionedScenario,
|
||||
runAllowBotsMentionsMentionedRoomScenario,
|
||||
runAllowBotsMentionsUnmentionedOpenRoomBlockScenario,
|
||||
runAllowBotsRoomOverrideBlocksAccountTrueScenario,
|
||||
runAllowBotsRoomOverrideEnablesAccountOffScenario,
|
||||
runAllowBotsSelfSenderIgnoredScenario,
|
||||
runAllowBotsTrueUnmentionedOpenRoomScenario,
|
||||
} from "./scenario-runtime-allowbots.js";
|
||||
import {
|
||||
runApprovalChannelTargetBothScenario,
|
||||
runApprovalDenyReactionScenario,
|
||||
runApprovalExecMetadataChunkedScenario,
|
||||
runApprovalExecMetadataSingleEventScenario,
|
||||
runApprovalPluginMetadataSingleEventScenario,
|
||||
runApprovalThreadTargetScenario,
|
||||
} from "./scenario-runtime-approval.js";
|
||||
import {
|
||||
runDmPerRoomSessionOverrideScenario,
|
||||
runDmSharedSessionNoticeScenario,
|
||||
runDmThreadReplyOverrideScenario,
|
||||
} from "./scenario-runtime-dm.js";
|
||||
import {
|
||||
runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario,
|
||||
runMatrixQaE2eeHistoryExistsBackupEmptyScenario,
|
||||
runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario,
|
||||
runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario,
|
||||
runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario,
|
||||
runMatrixQaE2eeStateLossExternalRecoveryKeyScenario,
|
||||
runMatrixQaE2eeStateLossNoRecoveryKeyScenario,
|
||||
runMatrixQaE2eeStateLossStoredRecoveryKeyScenario,
|
||||
runMatrixQaE2eeSyncStateLossCryptoIntactScenario,
|
||||
runMatrixQaE2eeWrongAccountRecoveryKeyScenario,
|
||||
} from "./scenario-runtime-e2ee-destructive.js";
|
||||
import {
|
||||
runMatrixQaE2eeArtifactRedactionScenario,
|
||||
runMatrixQaE2eeBasicReplyScenario,
|
||||
runMatrixQaE2eeBootstrapSuccessScenario,
|
||||
runMatrixQaE2eeCliAccountAddEnableE2eeScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupIdempotentScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupScenario,
|
||||
runMatrixQaE2eeCliRecoveryKeyInvalidScenario,
|
||||
runMatrixQaE2eeCliRecoveryKeySetupScenario,
|
||||
runMatrixQaE2eeCliSetupThenGatewayReplyScenario,
|
||||
runMatrixQaE2eeCliSelfVerificationScenario,
|
||||
runMatrixQaE2eeDeviceSasVerificationScenario,
|
||||
runMatrixQaE2eeDmSasVerificationScenario,
|
||||
runMatrixQaE2eeKeyBootstrapFailureScenario,
|
||||
runMatrixQaE2eeMediaImageScenario,
|
||||
runMatrixQaE2eeQrVerificationScenario,
|
||||
runMatrixQaE2eeRecoveryKeyLifecycleScenario,
|
||||
runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario,
|
||||
runMatrixQaE2eeRestartResumeScenario,
|
||||
runMatrixQaE2eeStateAfterMissingEncryptionScenario,
|
||||
runMatrixQaE2eeStaleDeviceHygieneScenario,
|
||||
runMatrixQaE2eeThreadFollowUpScenario,
|
||||
runMatrixQaE2eeVerificationNoticeNoTriggerScenario,
|
||||
} from "./scenario-runtime-e2ee.js";
|
||||
import {
|
||||
runInboundEditIgnoredScenario,
|
||||
runInboundEditNoDuplicateTriggerScenario,
|
||||
} from "./scenario-runtime-edit.js";
|
||||
import {
|
||||
runAttachmentOnlyIgnoredScenario,
|
||||
runGeneratedImageDeliveryScenario,
|
||||
runImageUnderstandingAttachmentScenario,
|
||||
runMediaTypeCoverageScenario,
|
||||
runUnsupportedMediaSafeScenario,
|
||||
runVoicePreflightMentionScenario,
|
||||
} 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,
|
||||
runPartialStreamingPreviewScenario,
|
||||
runQuietStreamingPreviewScenario,
|
||||
runReactionThreadedScenario,
|
||||
runRoomAutoJoinInviteScenario,
|
||||
runRoomThreadReplyOverrideScenario,
|
||||
runSubagentThreadSpawnScenario,
|
||||
runThreadFollowUpScenario,
|
||||
runThreadIsolationScenario,
|
||||
runThreadNestedReplyShapeScenario,
|
||||
runThreadRootPreservationScenario,
|
||||
runToolProgressErrorScenario,
|
||||
runToolProgressCommandPreviewScenario,
|
||||
runToolProgressMentionSafetyScenario,
|
||||
runToolProgressPreviewOptOutScenario,
|
||||
runToolProgressPreviewScenario,
|
||||
runTopLevelReplyShapeScenario,
|
||||
} from "./scenario-runtime-room.js";
|
||||
import {
|
||||
buildExactMarkerPrompt,
|
||||
buildMatrixQaToken,
|
||||
buildMatrixReplyArtifact,
|
||||
buildMatrixReplyDetails,
|
||||
buildMentionPrompt,
|
||||
readMatrixQaSyncCursor,
|
||||
resolveMatrixQaNoReplyWindowMs,
|
||||
runNoReplyExpectedScenario,
|
||||
runTopologyScopedTopLevelScenario,
|
||||
writeMatrixQaSyncCursor,
|
||||
type MatrixQaScenarioContext,
|
||||
} from "./scenario-runtime-shared.js";
|
||||
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
|
||||
|
||||
export {
|
||||
buildMatrixReplyArtifact,
|
||||
buildMatrixReplyDetails,
|
||||
buildMentionPrompt,
|
||||
readMatrixQaSyncCursor,
|
||||
runMatrixQaCanary,
|
||||
writeMatrixQaSyncCursor,
|
||||
};
|
||||
export type { MatrixQaScenarioContext };
|
||||
|
||||
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,
|
||||
syncStreams: params.context.syncStreams,
|
||||
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: resolveMatrixQaNoReplyWindowMs(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-partial-streaming-preview":
|
||||
return await runPartialStreamingPreviewScenario(context);
|
||||
case "matrix-room-quiet-streaming-preview":
|
||||
return await runQuietStreamingPreviewScenario(context);
|
||||
case "matrix-room-tool-progress-preview":
|
||||
return await runToolProgressPreviewScenario(context);
|
||||
case "matrix-room-tool-progress-command-preview":
|
||||
return await runToolProgressCommandPreviewScenario(context);
|
||||
case "matrix-room-tool-progress-preview-opt-out":
|
||||
return await runToolProgressPreviewOptOutScenario(context);
|
||||
case "matrix-room-tool-progress-error":
|
||||
return await runToolProgressErrorScenario(context);
|
||||
case "matrix-room-tool-progress-mention-safety":
|
||||
return await runToolProgressMentionSafetyScenario(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-voice-preflight-mention":
|
||||
return await runVoicePreflightMentionScenario(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-approval-exec-metadata-single-event":
|
||||
return await runApprovalExecMetadataSingleEventScenario(context);
|
||||
case "matrix-approval-exec-metadata-chunked":
|
||||
return await runApprovalExecMetadataChunkedScenario(context);
|
||||
case "matrix-approval-plugin-metadata-single-event":
|
||||
return await runApprovalPluginMetadataSingleEventScenario(context);
|
||||
case "matrix-approval-deny-reaction":
|
||||
return await runApprovalDenyReactionScenario(context);
|
||||
case "matrix-approval-thread-target":
|
||||
return await runApprovalThreadTargetScenario(context);
|
||||
case "matrix-approval-channel-target-both":
|
||||
return await runApprovalChannelTargetBothScenario(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-allowbots-default-block":
|
||||
return await runAllowBotsDefaultBlockScenario(context);
|
||||
case "matrix-allowbots-true-unmentioned-open-room":
|
||||
return await runAllowBotsTrueUnmentionedOpenRoomScenario(context);
|
||||
case "matrix-allowbots-mentions-mentioned-room":
|
||||
return await runAllowBotsMentionsMentionedRoomScenario(context);
|
||||
case "matrix-allowbots-mentions-unmentioned-open-room-block":
|
||||
return await runAllowBotsMentionsUnmentionedOpenRoomBlockScenario(context);
|
||||
case "matrix-allowbots-mentions-dm-unmentioned":
|
||||
return await runAllowBotsMentionsDmUnmentionedScenario(context);
|
||||
case "matrix-allowbots-room-override-blocks-account-true":
|
||||
return await runAllowBotsRoomOverrideBlocksAccountTrueScenario(context);
|
||||
case "matrix-allowbots-room-override-enables-account-off":
|
||||
return await runAllowBotsRoomOverrideEnablesAccountOffScenario(context);
|
||||
case "matrix-allowbots-self-sender-ignored":
|
||||
return await runAllowBotsSelfSenderIgnoredScenario(context);
|
||||
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-state-after-missing-encryption":
|
||||
return await runMatrixQaE2eeStateAfterMissingEncryptionScenario(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-recovery-owner-verification-required":
|
||||
return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context);
|
||||
case "matrix-e2ee-cli-account-add-enable-e2ee":
|
||||
return await runMatrixQaE2eeCliAccountAddEnableE2eeScenario(context);
|
||||
case "matrix-e2ee-cli-encryption-setup":
|
||||
return await runMatrixQaE2eeCliEncryptionSetupScenario(context);
|
||||
case "matrix-e2ee-cli-encryption-setup-idempotent":
|
||||
return await runMatrixQaE2eeCliEncryptionSetupIdempotentScenario(context);
|
||||
case "matrix-e2ee-cli-encryption-setup-bootstrap-failure":
|
||||
return await runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario(context);
|
||||
case "matrix-e2ee-cli-recovery-key-setup":
|
||||
return await runMatrixQaE2eeCliRecoveryKeySetupScenario(context);
|
||||
case "matrix-e2ee-cli-recovery-key-invalid":
|
||||
return await runMatrixQaE2eeCliRecoveryKeyInvalidScenario(context);
|
||||
case "matrix-e2ee-cli-encryption-setup-multi-account":
|
||||
return await runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario(context);
|
||||
case "matrix-e2ee-cli-setup-then-gateway-reply":
|
||||
return await runMatrixQaE2eeCliSetupThenGatewayReplyScenario(context);
|
||||
case "matrix-e2ee-cli-self-verification":
|
||||
return await runMatrixQaE2eeCliSelfVerificationScenario(context);
|
||||
case "matrix-e2ee-state-loss-external-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossExternalRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-state-loss-stored-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossStoredRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-state-loss-no-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossNoRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-stale-recovery-key-after-backup-reset":
|
||||
return await runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario(context);
|
||||
case "matrix-e2ee-server-backup-deleted-local-state-intact":
|
||||
return await runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario(context);
|
||||
case "matrix-e2ee-server-backup-deleted-local-reupload-restores":
|
||||
return await runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario(context);
|
||||
case "matrix-e2ee-corrupt-crypto-idb-snapshot":
|
||||
return await runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario(context);
|
||||
case "matrix-e2ee-server-device-deleted-local-state-intact":
|
||||
return await runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario(context);
|
||||
case "matrix-e2ee-server-device-deleted-relogin-recovers":
|
||||
return await runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario(context);
|
||||
case "matrix-e2ee-sync-state-loss-crypto-intact":
|
||||
return await runMatrixQaE2eeSyncStateLossCryptoIntactScenario(context);
|
||||
case "matrix-e2ee-wrong-account-recovery-key":
|
||||
return await runMatrixQaE2eeWrongAccountRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-history-exists-backup-empty":
|
||||
return await runMatrixQaE2eeHistoryExistsBackupEmptyScenario(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
186
extensions/qa-matrix/src/runners/contract/scenario-types.ts
Normal file
186
extensions/qa-matrix/src/runners/contract/scenario-types.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// Qa Matrix plugin module implements scenario types behavior.
|
||||
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;
|
||||
approval?: MatrixQaObservedEvent["approval"] & {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
};
|
||||
approvals?: Array<
|
||||
MatrixQaObservedEvent["approval"] & {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
}
|
||||
>;
|
||||
attachments?: Array<{
|
||||
eventId: string;
|
||||
filename?: string;
|
||||
kind?: string;
|
||||
label: string;
|
||||
msgtype?: string;
|
||||
}>;
|
||||
attachmentCaptionPreview?: string;
|
||||
attachmentBodyPreview?: string;
|
||||
attachmentEventId?: string;
|
||||
attachmentFilename?: string;
|
||||
attachmentKind?: string;
|
||||
attachmentMsgtype?: string;
|
||||
accountId?: string;
|
||||
actorUserId?: string;
|
||||
blocked?: MatrixQaScenarioArtifacts;
|
||||
catchupDriverEventId?: string;
|
||||
catchupReply?: MatrixQaReplyArtifact;
|
||||
catchupToken?: string;
|
||||
dedupeCommitObserved?: boolean;
|
||||
duplicateWindowMs?: number;
|
||||
driverEventId?: string;
|
||||
driverEventIds?: string[];
|
||||
driverUserId?: string;
|
||||
editEventId?: string;
|
||||
editedToken?: string;
|
||||
expectedNoReplyWindowMs?: number;
|
||||
expectedMarker?: string;
|
||||
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;
|
||||
previewFormattedBodyPreview?: string;
|
||||
previewMentions?: MatrixQaObservedEvent["mentions"];
|
||||
blockEventIds?: string[];
|
||||
bootstrapActor?: "driver" | "observer" | "sut";
|
||||
bootstrapErrorPreview?: string;
|
||||
bootstrapSuccess?: boolean;
|
||||
backupCreatedVersion?: string | null;
|
||||
backupDeletedHttpStatus?: number;
|
||||
backupPreviousVersion?: string | null;
|
||||
backupRestored?: boolean;
|
||||
backupReset?: boolean;
|
||||
completedVerificationId?: string;
|
||||
backupVersion?: string | null;
|
||||
cliDeviceId?: string | null;
|
||||
completedVerificationIds?: string[];
|
||||
currentDeviceId?: string | null;
|
||||
accountRoot?: string;
|
||||
corruptedPath?: string;
|
||||
deletedSyncStorePath?: string;
|
||||
deletedDeviceIds?: string[];
|
||||
deletedDeviceId?: string;
|
||||
deletedBackupVersion?: string | null;
|
||||
faultedEndpoint?: string;
|
||||
faultHitCount?: number;
|
||||
faultProxyBaseUrl?: string;
|
||||
faultRuleId?: string;
|
||||
historyEventId?: string;
|
||||
observerRecoveryDeviceId?: string;
|
||||
qrBytes?: number;
|
||||
recoveryDeviceId?: string;
|
||||
recoveryKeyPreserved?: boolean;
|
||||
decoyAccountPreserved?: boolean;
|
||||
defaultAccountPreserved?: boolean;
|
||||
recoveryKeyAccepted?: boolean;
|
||||
recoveryKeyId?: string | null;
|
||||
recoveryKeyRejected?: boolean;
|
||||
recoveryKeyStored?: boolean;
|
||||
rotatedRecoveryKeyId?: string | null;
|
||||
remainingDeviceIds?: string[];
|
||||
restoreError?: string;
|
||||
restoreErrorAfterDelete?: string;
|
||||
restoreExitCode?: number;
|
||||
restoreImported?: number;
|
||||
restoreTotal?: number;
|
||||
sasEmoji?: string[];
|
||||
secondaryDeviceId?: string;
|
||||
seededEventId?: string;
|
||||
replyEventId?: string;
|
||||
statusError?: string;
|
||||
statusExitCode?: number;
|
||||
defaultStatusError?: string;
|
||||
defaultStatusExitCode?: number;
|
||||
serverDeviceKnown?: boolean | null;
|
||||
replacementDeviceId?: string;
|
||||
selfVerificationTransactionId?: string | null;
|
||||
transportInterruption?: string;
|
||||
encryptionChanged?: boolean;
|
||||
encryptionEnabled?: boolean;
|
||||
firstEncryptionChanged?: boolean;
|
||||
gatewayUserId?: string;
|
||||
secondEncryptionChanged?: boolean;
|
||||
setupSuccess?: boolean;
|
||||
stateAfterFaultHitCount?: number;
|
||||
stateAfterFaultRuleId?: string;
|
||||
strippedSyncStateAfterParam?: boolean;
|
||||
verificationBootstrapAttempted?: boolean;
|
||||
verificationBootstrapSuccess?: boolean;
|
||||
gatewayReply?: MatrixQaReplyArtifact;
|
||||
verificationRoomId?: string;
|
||||
joinedRoomId?: string;
|
||||
localEventId?: string;
|
||||
verificationExitCode?: number;
|
||||
};
|
||||
|
||||
export type MatrixQaScenarioExecution = {
|
||||
artifacts?: MatrixQaScenarioArtifacts;
|
||||
details: string;
|
||||
};
|
||||
7502
extensions/qa-matrix/src/runners/contract/scenarios.test.ts
Normal file
7502
extensions/qa-matrix/src/runners/contract/scenarios.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
53
extensions/qa-matrix/src/runners/contract/scenarios.ts
Normal file
53
extensions/qa-matrix/src/runners/contract/scenarios.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Qa Matrix plugin module implements scenarios behavior.
|
||||
import {
|
||||
MATRIX_QA_DRIVER_DM_ROOM_KEY,
|
||||
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
|
||||
MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
MATRIX_QA_SCENARIOS,
|
||||
MATRIX_QA_SECONDARY_ROOM_KEY,
|
||||
MATRIX_QA_STANDARD_SCENARIO_IDS,
|
||||
buildMatrixQaE2eeScenarioRoomKey,
|
||||
buildMatrixQaTopologyForScenarios,
|
||||
findMatrixQaScenarios,
|
||||
resolveMatrixQaScenarioRoomId,
|
||||
matrixQaProfileTesting,
|
||||
} from "./scenario-catalog.js";
|
||||
import {
|
||||
buildMatrixReplyArtifact,
|
||||
buildMatrixReplyDetails,
|
||||
buildMentionPrompt,
|
||||
runMatrixQaCanary,
|
||||
runMatrixQaScenario,
|
||||
type MatrixQaScenarioContext,
|
||||
} from "./scenario-runtime.js";
|
||||
import type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts } from "./scenario-types.js";
|
||||
|
||||
export {
|
||||
MATRIX_QA_SCENARIOS,
|
||||
buildMatrixReplyDetails,
|
||||
buildMatrixQaTopologyForScenarios,
|
||||
findMatrixQaScenarios,
|
||||
runMatrixQaCanary,
|
||||
runMatrixQaScenario,
|
||||
};
|
||||
export type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts };
|
||||
|
||||
export type { MatrixQaScenarioContext };
|
||||
|
||||
export const testing = {
|
||||
MATRIX_QA_DRIVER_DM_ROOM_KEY,
|
||||
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
|
||||
MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
MATRIX_QA_SECONDARY_ROOM_KEY,
|
||||
MATRIX_QA_STANDARD_SCENARIO_IDS,
|
||||
buildMatrixQaE2eeScenarioRoomKey,
|
||||
buildMatrixQaTopologyForScenarios,
|
||||
buildMatrixReplyDetails,
|
||||
buildMatrixReplyArtifact,
|
||||
buildMentionPrompt,
|
||||
findMatrixQaScenarios,
|
||||
getMatrixQaProfileScenarioIds: matrixQaProfileTesting.getMatrixQaProfileScenarioIds,
|
||||
normalizeMatrixQaProfile: matrixQaProfileTesting.normalizeMatrixQaProfile,
|
||||
resolveMatrixQaScenarioRoomId,
|
||||
};
|
||||
export { testing as __testing };
|
||||
10
extensions/qa-matrix/src/runtime-api.test.ts
Normal file
10
extensions/qa-matrix/src/runtime-api.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Qa Matrix tests cover runtime api plugin behavior.
|
||||
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,6 @@
|
||||
// Qa Matrix plugin module implements live transport artifact behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export function createLiveTransportQaRunId() {
|
||||
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Qa Matrix tests cover live transport cli plugin behavior.
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startLiveTransportQaOutputTee } from "openclaw/plugin-sdk/qa-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveLiveTransportQaRunOptions } 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("uses unique default output dirs for CLI runs", () => {
|
||||
const repoRoot = "/repo";
|
||||
const firstOutputDir = resolveLiveTransportQaRunOptions({ repoRoot }).outputDir;
|
||||
const secondOutputDir = resolveLiveTransportQaRunOptions({ repoRoot }).outputDir;
|
||||
|
||||
expect(path.dirname(firstOutputDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
|
||||
expect(path.basename(firstOutputDir)).toMatch(/^matrix-[a-z0-9]+-[a-f0-9]{8}$/u);
|
||||
expect(secondOutputDir).not.toBe(firstOutputDir);
|
||||
expect(
|
||||
resolveLiveTransportQaRunOptions({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/custom",
|
||||
}).outputDir,
|
||||
).toBe(path.join(repoRoot, ".artifacts/custom"));
|
||||
});
|
||||
|
||||
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");
|
||||
let stopError: unknown;
|
||||
try {
|
||||
await tee.stop();
|
||||
} catch (caught) {
|
||||
stopError = caught;
|
||||
}
|
||||
expect(stopError).toBeInstanceOf(Error);
|
||||
expect((stopError as NodeJS.ErrnoException).code).toBe("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,37 @@
|
||||
// Qa Matrix plugin module implements live transport cli behavior.
|
||||
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 { createLiveTransportQaRunId } from "./live-transport-artifacts.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-${createLiveTransportQaRunId()}`);
|
||||
return {
|
||||
repoRoot,
|
||||
outputDir,
|
||||
providerMode:
|
||||
opts.providerMode === undefined
|
||||
? "live-frontier"
|
||||
: normalizeQaProviderMode(opts.providerMode),
|
||||
primaryModel: opts.primaryModel,
|
||||
alternateModel: opts.alternateModel,
|
||||
fastMode: opts.fastMode,
|
||||
failFast: opts.failFast,
|
||||
profile: opts.profile?.trim(),
|
||||
scenarioIds: opts.scenarioIds,
|
||||
sutAccountId: opts.sutAccountId,
|
||||
credentialSource: opts.credentialSource?.trim(),
|
||||
credentialRole: opts.credentialRole?.trim(),
|
||||
};
|
||||
}
|
||||
27
extensions/qa-matrix/src/shared/live-transport-cli.ts
Normal file
27
extensions/qa-matrix/src/shared/live-transport-cli.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Qa Matrix plugin module implements live transport cli behavior.
|
||||
import {
|
||||
createLiveTransportQaCliRegistration as createSharedLiveTransportQaCliRegistration,
|
||||
type LiveTransportQaCliRegistrationOptions,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
|
||||
export {
|
||||
createLazyCliRuntimeLoader,
|
||||
type LiveTransportQaCliRegistration,
|
||||
type LiveTransportQaCommandOptions,
|
||||
} from "openclaw/plugin-sdk/qa-runtime";
|
||||
|
||||
type MatrixLiveTransportQaCliRegistrationOptions = Omit<
|
||||
LiveTransportQaCliRegistrationOptions,
|
||||
"defaultProviderMode" | "providerModeHelp"
|
||||
>;
|
||||
|
||||
export function createLiveTransportQaCliRegistration(
|
||||
params: MatrixLiveTransportQaCliRegistrationOptions,
|
||||
) {
|
||||
return createSharedLiveTransportQaCliRegistration({
|
||||
...params,
|
||||
defaultProviderMode: "live-frontier",
|
||||
providerModeHelp:
|
||||
"Provider mode: mock-openai or live-frontier (legacy live-openai still works)",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Qa Matrix plugin module implements live transport scenarios behavior.
|
||||
export {
|
||||
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
|
||||
collectLiveTransportStandardScenarioCoverage,
|
||||
findMissingLiveTransportStandardScenarios,
|
||||
selectLiveTransportScenarios,
|
||||
type LiveTransportScenarioDefinition,
|
||||
type LiveTransportStandardScenarioId,
|
||||
} from "openclaw/plugin-sdk/qa-live-transport-scenarios";
|
||||
209
extensions/qa-matrix/src/substrate/artifacts.test.ts
Normal file
209
extensions/qa-matrix/src/substrate/artifacts.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// Qa Matrix tests cover artifacts plugin behavior.
|
||||
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 approval summaries in redacted Matrix observed-event artifacts", () => {
|
||||
expect(
|
||||
buildMatrixQaObservedEventsArtifact({
|
||||
includeContent: false,
|
||||
observedEvents: [
|
||||
{
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
body: "secret command body",
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
state: "pending",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
hasCommandText: true,
|
||||
commandTextPreview: "printf MATRIX_QA",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
msgtype: undefined,
|
||||
membership: undefined,
|
||||
relatesTo: undefined,
|
||||
mentions: undefined,
|
||||
reaction: undefined,
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
state: "pending",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
hasCommandText: true,
|
||||
commandTextPreview: "printf MATRIX_QA",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
33
extensions/qa-matrix/src/substrate/artifacts.ts
Normal file
33
extensions/qa-matrix/src/substrate/artifacts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Qa Matrix plugin module implements artifacts behavior.
|
||||
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,
|
||||
...(event.approval ? { approval: event.approval } : {}),
|
||||
attachment: event.attachment
|
||||
? {
|
||||
kind: event.attachment.kind,
|
||||
...(event.attachment.filename ? { filename: event.attachment.filename } : {}),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
737
extensions/qa-matrix/src/substrate/client.test.ts
Normal file
737
extensions/qa-matrix/src/substrate/client.test.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
// Qa Matrix tests cover client plugin behavior.
|
||||
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,
|
||||
});
|
||||
|
||||
const login = await client.loginWithPassword({
|
||||
deviceName: "OpenClaw Matrix QA Stale Device",
|
||||
password: "driver-password",
|
||||
userId: "@qa-driver:matrix-qa.test",
|
||||
});
|
||||
expect(login.accessToken).toBe("secondary-token");
|
||||
expect(login.deviceId).toBe("SECONDARYDEVICE");
|
||||
expect(login.password).toBe("driver-password");
|
||||
expect(login.userId).toBe("@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/",
|
||||
);
|
||||
const relation = requests[0]?.body?.["m.relates_to"] as
|
||||
| { event_id?: string; rel_type?: string }
|
||||
| undefined;
|
||||
expect(relation?.rel_type).toBe("m.replace");
|
||||
expect(relation?.event_id).toBe("$msg-1");
|
||||
expect(requests[1]?.url).toMatch(
|
||||
/^http:\/\/127\.0\.0\.1:28008\/_matrix\/client\/v3\/rooms\/!room%3Amatrix-qa\.test\/redact\/%24reaction-1\/[0-9a-f-]{36}$/,
|
||||
);
|
||||
expect(requests[1]?.body).toEqual({
|
||||
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/",
|
||||
);
|
||||
const messageBody =
|
||||
typeof requests[1]?.body === "string" ? JSON.parse(requests[1].body) : requests[1]?.body;
|
||||
expect(messageBody.body).toBe("@sut:matrix-qa.test Image understanding check");
|
||||
expect(messageBody.msgtype).toBe("m.image");
|
||||
expect(messageBody.filename).toBe("red-top-blue-bottom.png");
|
||||
expect(messageBody.url).toBe("mxc://matrix-qa.test/red-top-blue-bottom");
|
||||
expect(messageBody.info?.mimetype).toBe("image/png");
|
||||
expect(messageBody.info?.size).toBe("png-bytes".length);
|
||||
expect(messageBody["m.mentions"]?.user_ids).toEqual(["@sut:matrix-qa.test"]);
|
||||
});
|
||||
|
||||
it("fails closed when the media upload response streams an over-cap body", async () => {
|
||||
// Sibling coverage to requestMatrixJson: the /_matrix/media/v3/upload
|
||||
// response is also parsed from an external homeserver, so an oversized
|
||||
// upload body must trip the same 16 MiB cap and cancel the stream rather
|
||||
// than buffering it whole and OOMing the QA runner.
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 32; // 32 MiB total, past the 16 MiB cap
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(chunkSize)));
|
||||
if (reads >= chunkCount) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ 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: "huge.png",
|
||||
kind: "image",
|
||||
mentionUserIds: ["@sut:matrix-qa.test"],
|
||||
roomId: "!room:matrix-qa.test",
|
||||
}),
|
||||
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
|
||||
|
||||
expect(canceled).toBe(true);
|
||||
expect(reads).toBeLessThan(chunkCount);
|
||||
});
|
||||
|
||||
it("still tolerates malformed in-bounds media upload JSON", async () => {
|
||||
// Malformed-but-in-bounds upload bodies fall back to `{}`, so the upload
|
||||
// surfaces the pre-existing "did not return content_uri" error rather than
|
||||
// a parse crash — unchanged from before the bound was added.
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response("{ not json", {
|
||||
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: "bad.png",
|
||||
kind: "image",
|
||||
mentionUserIds: ["@sut:matrix-qa.test"],
|
||||
roomId: "!room:matrix-qa.test",
|
||||
}),
|
||||
).rejects.toThrow("Matrix media upload did not return content_uri.");
|
||||
});
|
||||
|
||||
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).toStrictEqual([
|
||||
{
|
||||
creation_content: { "m.federate": false },
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: { history_visibility: "joined" },
|
||||
},
|
||||
{
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
},
|
||||
],
|
||||
invite: ["@sut:matrix-qa.test"],
|
||||
is_direct: false,
|
||||
name: "Encrypted QA Room",
|
||||
preset: "private_chat",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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).toEqual({
|
||||
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",
|
||||
name: "OpenClaw Matrix QA",
|
||||
encrypted: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.observer.userId).toBe("@qa-observer:matrix-qa.test");
|
||||
expect(createRoomBodies).toEqual([
|
||||
{
|
||||
creation_content: { "m.federate": false },
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: { history_visibility: "joined" },
|
||||
},
|
||||
],
|
||||
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
|
||||
is_direct: false,
|
||||
name: "OpenClaw Matrix QA",
|
||||
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).toEqual([
|
||||
{
|
||||
encrypted: false,
|
||||
key: "group",
|
||||
kind: "group",
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@qa-driver:matrix-qa.test",
|
||||
"@qa-observer:matrix-qa.test",
|
||||
"@qa-sut:matrix-qa.test",
|
||||
],
|
||||
name: "Matrix Group",
|
||||
requireMention: true,
|
||||
roomId: "!group:matrix-qa.test",
|
||||
},
|
||||
{
|
||||
encrypted: false,
|
||||
key: "sut-dm",
|
||||
kind: "dm",
|
||||
memberRoles: ["driver", "sut"],
|
||||
memberUserIds: ["@qa-driver:matrix-qa.test", "@qa-sut:matrix-qa.test"],
|
||||
name: "Matrix Driver/SUT DM",
|
||||
requireMention: false,
|
||||
roomId: "!dm:matrix-qa.test",
|
||||
},
|
||||
]);
|
||||
expect(createRoomBodies).toEqual([
|
||||
{
|
||||
creation_content: { "m.federate": false },
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: { history_visibility: "joined" },
|
||||
},
|
||||
],
|
||||
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
|
||||
is_direct: false,
|
||||
name: "Matrix Group",
|
||||
preset: "private_chat",
|
||||
},
|
||||
{
|
||||
creation_content: { "m.federate": false },
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: { history_visibility: "joined" },
|
||||
},
|
||||
],
|
||||
invite: ["@qa-sut:matrix-qa.test"],
|
||||
is_direct: true,
|
||||
name: "Matrix Driver/SUT DM",
|
||||
preset: "private_chat",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
928
extensions/qa-matrix/src/substrate/client.ts
Normal file
928
extensions/qa-matrix/src/substrate/client.ts
Normal file
@@ -0,0 +1,928 @@
|
||||
// Qa Matrix plugin module implements client behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MatrixQaObservedEvent } from "./events.js";
|
||||
import { MATRIX_QA_JSON_MAX_BYTES, requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
|
||||
import {
|
||||
createMatrixQaRoomObserver,
|
||||
primeMatrixQaRoom,
|
||||
waitForMatrixQaRoomEvent,
|
||||
waitForOptionalMatrixQaRoomEvent,
|
||||
type MatrixQaRoomObserver,
|
||||
} from "./sync.js";
|
||||
import {
|
||||
findMatrixQaProvisionedRoom,
|
||||
type MatrixQaParticipantRole,
|
||||
type MatrixQaProvisionedTopology,
|
||||
type MatrixQaTopologyRoomSpec,
|
||||
type MatrixQaTopologySpec,
|
||||
} from "./topology.js";
|
||||
|
||||
export type { 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;
|
||||
};
|
||||
|
||||
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 = uniqueStrings(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),
|
||||
});
|
||||
// Bound the media-upload response body before parsing, mirroring
|
||||
// `requestMatrixJson`. The overflow error is read *outside* the parse
|
||||
// try/catch so it fails closed (propagates) instead of being swallowed into
|
||||
// `{}`; malformed-but-in-bounds JSON still falls back to `{}` as before.
|
||||
const uploadBytes = await readResponseWithLimit(response, MATRIX_QA_JSON_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) => new Error(`Matrix homeserver response exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
let body: { content_uri?: string; error?: string };
|
||||
try {
|
||||
body = JSON.parse(new TextDecoder().decode(uploadBytes)) as {
|
||||
content_uri?: string;
|
||||
error?: string;
|
||||
};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 = uniqueValues(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,
|
||||
};
|
||||
export { testing as __testing };
|
||||
431
extensions/qa-matrix/src/substrate/config.test.ts
Normal file
431
extensions/qa-matrix/src/substrate/config.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Qa Matrix tests cover config plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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,
|
||||
});
|
||||
|
||||
const sut = next.channels?.matrix?.accounts?.sut;
|
||||
expect(sut?.dm?.allowFrom).toEqual(["@driver:matrix-qa.test"]);
|
||||
expect(sut?.dm?.enabled).toBe(true);
|
||||
expect(sut?.dm?.policy).toBe("allowlist");
|
||||
expect(sut?.groupAllowFrom).toEqual(["@driver:matrix-qa.test"]);
|
||||
expect(sut?.groupPolicy).toBe("allowlist");
|
||||
expect(sut?.groups?.["!main:matrix-qa.test"]).toEqual({
|
||||
enabled: true,
|
||||
requireMention: true,
|
||||
});
|
||||
expect(sut?.groups?.["!secondary:matrix-qa.test"]).toEqual({
|
||||
enabled: true,
|
||||
requireMention: true,
|
||||
});
|
||||
expect(sut?.replyToMode).toBe("off");
|
||||
expect(sut?.threadReplies).toBe("inbound");
|
||||
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
});
|
||||
|
||||
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,
|
||||
allowBots: "mentions",
|
||||
configuredBotRoles: ["observer"],
|
||||
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
|
||||
groupMentionPatterns: ["\\S"],
|
||||
groupsByKey: {
|
||||
secondary: {
|
||||
allowBots: false,
|
||||
requireMention: false,
|
||||
tools: {
|
||||
allow: ["sessions_spawn"],
|
||||
},
|
||||
},
|
||||
},
|
||||
replyToMode: "all",
|
||||
streaming: "quiet",
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 1,
|
||||
spawnSessions: true,
|
||||
},
|
||||
threadReplies: "always",
|
||||
audio: {
|
||||
echoTranscript: false,
|
||||
enabled: true,
|
||||
},
|
||||
toolProfile: "coding",
|
||||
},
|
||||
observerAccessToken: "observer-token",
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(next.agents?.defaults?.blockStreamingChunk).toEqual({
|
||||
breakPreference: "newline",
|
||||
maxChars: 48,
|
||||
minChars: 1,
|
||||
});
|
||||
expect(next.agents?.defaults?.blockStreamingCoalesce).toEqual({
|
||||
idleMs: 0,
|
||||
maxChars: 48,
|
||||
minChars: 1,
|
||||
});
|
||||
expect(next.tools?.profile).toBe("coding");
|
||||
expect(next.tools?.media?.audio).toEqual({
|
||||
echoTranscript: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(next.messages?.groupChat?.mentionPatterns).toEqual(["\\S"]);
|
||||
const observer = next.channels?.matrix?.accounts?.["qa-observer-bot-source"];
|
||||
expect(observer?.accessToken).toBe("observer-token");
|
||||
expect(observer?.enabled).toBe(false);
|
||||
expect(observer?.homeserver).toBe("http://127.0.0.1:28008/");
|
||||
expect(observer?.userId).toBe("@observer:matrix-qa.test");
|
||||
const sut = next.channels?.matrix?.accounts?.sut;
|
||||
expect(sut?.allowBots).toBe("mentions");
|
||||
expect(sut?.autoJoin).toBe("allowlist");
|
||||
expect(sut?.autoJoinAllowlist).toEqual(["!dm:matrix-qa.test", "#ops:matrix-qa.test"]);
|
||||
expect(sut?.blockStreaming).toBe(true);
|
||||
expect(sut?.dm?.sessionScope).toBe("per-room");
|
||||
expect(sut?.dm?.threadReplies).toBe("off");
|
||||
expect(sut?.encryption).toBe(true);
|
||||
expect(sut?.groupAllowFrom).toEqual(["@driver:matrix-qa.test", "@observer:matrix-qa.test"]);
|
||||
expect(sut?.groups?.["!main:matrix-qa.test"]).toEqual({
|
||||
enabled: true,
|
||||
requireMention: true,
|
||||
});
|
||||
expect(sut?.groups?.["!secondary:matrix-qa.test"]).toEqual({
|
||||
allowBots: false,
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
tools: {
|
||||
allow: ["sessions_spawn"],
|
||||
},
|
||||
});
|
||||
expect(sut?.replyToMode).toBe("all");
|
||||
expect(sut?.streaming).toBe("quiet");
|
||||
expect(sut?.threadBindings).toEqual({
|
||||
enabled: true,
|
||||
idleHours: 1,
|
||||
spawnSessions: true,
|
||||
});
|
||||
expect(sut?.threadReplies).toBe("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",
|
||||
},
|
||||
groupMentionPatterns: ["\\S"],
|
||||
groupPolicy: "open",
|
||||
streaming: true,
|
||||
},
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
approvalForwarding: {
|
||||
exec: false,
|
||||
plugin: false,
|
||||
},
|
||||
allowBots: undefined,
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["!ops:matrix-qa.test"],
|
||||
blockStreaming: true,
|
||||
chunkMode: undefined,
|
||||
dm: {
|
||||
allowFrom: ["@driver:matrix-qa.test"],
|
||||
enabled: true,
|
||||
policy: "allowlist",
|
||||
sessionScope: "per-room",
|
||||
threadReplies: "inbound",
|
||||
},
|
||||
encryption: false,
|
||||
execApprovals: undefined,
|
||||
configuredBotRoles: [],
|
||||
groupAllowFrom: ["@driver:matrix-qa.test"],
|
||||
groupMentionPatterns: ["\\S"],
|
||||
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",
|
||||
streamingPreviewToolProgress: true,
|
||||
textChunkLimit: undefined,
|
||||
threadBindings: {},
|
||||
threadReplies: "inbound",
|
||||
});
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("allowBots=<default>");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("configuredBotRoles=<none>");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("groupMentionPatterns=\\S");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("autoJoin=allowlist");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=partial");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain(
|
||||
"streaming.preview.toolProgress=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds Matrix QA config snapshots from structured streaming overrides", () => {
|
||||
const snapshot = buildMatrixQaConfigSnapshot({
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
overrides: {
|
||||
streaming: {
|
||||
mode: "quiet",
|
||||
preview: {
|
||||
toolProgress: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(snapshot.streaming).toBe("quiet");
|
||||
expect(snapshot.streamingPreviewToolProgress).toBe(false);
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=quiet");
|
||||
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain(
|
||||
"streaming.preview.toolProgress=false",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies Matrix approval delivery overrides with gateway forwarding enabled", () => {
|
||||
const next = buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
overrides: {
|
||||
approvalForwarding: {
|
||||
exec: true,
|
||||
plugin: true,
|
||||
},
|
||||
chunkMode: "length",
|
||||
dm: {
|
||||
enabled: true,
|
||||
},
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
target: "both",
|
||||
},
|
||||
textChunkLimit: 280,
|
||||
},
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(next.approvals?.exec).toEqual({ enabled: true, mode: "session" });
|
||||
expect(next.approvals?.plugin).toEqual({ enabled: true, mode: "session" });
|
||||
const sut = next.channels?.matrix?.accounts?.sut;
|
||||
expect(sut?.chunkMode).toBe("length");
|
||||
expect(sut?.dm?.allowFrom).toEqual(["@driver:matrix-qa.test"]);
|
||||
expect(sut?.dm?.enabled).toBe(true);
|
||||
expect(sut?.execApprovals).toEqual({
|
||||
enabled: true,
|
||||
target: "both",
|
||||
});
|
||||
expect(sut?.textChunkLimit).toBe(280);
|
||||
});
|
||||
|
||||
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 configured bot roles without matching side-account auth", () => {
|
||||
expect(() =>
|
||||
buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
overrides: {
|
||||
configuredBotRoles: ["observer"],
|
||||
},
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
}),
|
||||
).toThrow('Matrix QA configured bot role "observer" requires an access token');
|
||||
});
|
||||
|
||||
it("rejects the SUT role as a configured bot source", () => {
|
||||
expect(() =>
|
||||
buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
overrides: {
|
||||
configuredBotRoles: ["sut"],
|
||||
},
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
}),
|
||||
).toThrow('Matrix QA configured bot role "sut" would match the SUT account itself');
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
710
extensions/qa-matrix/src/substrate/config.ts
Normal file
710
extensions/qa-matrix/src/substrate/config.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
// Qa Matrix helper module supports config behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MatrixQaProvisionedTopology } from "./topology.js";
|
||||
|
||||
type MatrixQaReplyToMode = "off" | "first" | "all" | "batched";
|
||||
type MatrixQaThreadRepliesMode = "off" | "inbound" | "always";
|
||||
type MatrixQaDmPolicy = "allowlist" | "disabled" | "open" | "pairing";
|
||||
type MatrixQaGroupPolicy = "allowlist" | "disabled" | "open";
|
||||
type MatrixQaAutoJoinMode = "allowlist" | "always" | "off";
|
||||
type MatrixQaStreamingMode = "off" | "partial" | "quiet";
|
||||
type MatrixQaActorRole = "driver" | "observer" | "sut";
|
||||
type MatrixQaChunkMode = "length" | "newline";
|
||||
type MatrixQaExecApprovalTarget = "both" | "channel" | "dm";
|
||||
type MatrixQaExecApprovalsEnabled = boolean | "auto";
|
||||
type MatrixQaAllowBotsMode = boolean | "mentions";
|
||||
|
||||
type MatrixQaStreamingConfig = {
|
||||
mode?: MatrixQaStreamingMode;
|
||||
preview?: {
|
||||
toolProgress?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type MatrixQaAgentDefaultsOverrides = {
|
||||
blockStreamingChunk?: {
|
||||
breakPreference?: "newline" | "paragraph" | "sentence";
|
||||
maxChars?: number;
|
||||
minChars?: number;
|
||||
};
|
||||
blockStreamingCoalesce?: {
|
||||
idleMs?: number;
|
||||
maxChars?: number;
|
||||
minChars?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type MatrixQaToolConfigOverrides = {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
|
||||
type MatrixQaAudioConfigOverrides = NonNullable<
|
||||
NonNullable<NonNullable<OpenClawConfig["tools"]>["media"]>["audio"]
|
||||
>;
|
||||
|
||||
type MatrixQaGroupConfigOverrides = {
|
||||
allowBots?: MatrixQaAllowBotsMode;
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
tools?: MatrixQaToolConfigOverrides;
|
||||
};
|
||||
|
||||
type MatrixQaDmConfigOverrides = {
|
||||
allowFrom?: string[];
|
||||
enabled?: boolean;
|
||||
policy?: MatrixQaDmPolicy;
|
||||
sessionScope?: "per-room" | "per-user";
|
||||
threadReplies?: MatrixQaThreadRepliesMode;
|
||||
};
|
||||
|
||||
type MatrixQaThreadBindingsConfigOverrides = {
|
||||
enabled?: boolean;
|
||||
idleHours?: number;
|
||||
maxAgeHours?: number;
|
||||
spawnSessions?: boolean;
|
||||
defaultSpawnContext?: "isolated" | "fork";
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnAcpSessions?: boolean;
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnSubagentSessions?: boolean;
|
||||
};
|
||||
|
||||
type MatrixQaExecApprovalsConfigOverrides = {
|
||||
agentFilter?: string[];
|
||||
approvers?: string[];
|
||||
enabled?: MatrixQaExecApprovalsEnabled;
|
||||
sessionFilter?: string[];
|
||||
target?: MatrixQaExecApprovalTarget;
|
||||
};
|
||||
|
||||
export type MatrixQaConfigOverrides = {
|
||||
approvalForwarding?: {
|
||||
exec?: boolean;
|
||||
plugin?: boolean;
|
||||
};
|
||||
agentDefaults?: MatrixQaAgentDefaultsOverrides;
|
||||
allowBots?: MatrixQaAllowBotsMode;
|
||||
autoJoin?: MatrixQaAutoJoinMode;
|
||||
autoJoinAllowlist?: string[];
|
||||
blockStreaming?: boolean;
|
||||
chunkMode?: MatrixQaChunkMode;
|
||||
dm?: MatrixQaDmConfigOverrides;
|
||||
encryption?: boolean;
|
||||
execApprovals?: MatrixQaExecApprovalsConfigOverrides;
|
||||
groupAllowFrom?: string[];
|
||||
groupAllowRoles?: MatrixQaActorRole[];
|
||||
groupMentionPatterns?: string[];
|
||||
groupPolicy?: MatrixQaGroupPolicy;
|
||||
configuredBotRoles?: MatrixQaActorRole[];
|
||||
groupsByKey?: Record<string, MatrixQaGroupConfigOverrides>;
|
||||
replyToMode?: MatrixQaReplyToMode;
|
||||
startupVerification?: "if-unverified" | "off";
|
||||
streaming?: MatrixQaStreamingMode | MatrixQaStreamingConfig | boolean;
|
||||
textChunkLimit?: number;
|
||||
threadBindings?: MatrixQaThreadBindingsConfigOverrides;
|
||||
threadReplies?: MatrixQaThreadRepliesMode;
|
||||
audio?: MatrixQaAudioConfigOverrides;
|
||||
toolProfile?: "coding" | "messaging" | "minimal";
|
||||
};
|
||||
|
||||
export type MatrixQaConfigSnapshot = {
|
||||
approvalForwarding: {
|
||||
exec: boolean;
|
||||
plugin: boolean;
|
||||
};
|
||||
autoJoin: MatrixQaAutoJoinMode;
|
||||
autoJoinAllowlist: string[];
|
||||
allowBots?: MatrixQaAllowBotsMode;
|
||||
blockStreaming: boolean;
|
||||
chunkMode?: MatrixQaChunkMode;
|
||||
dm: {
|
||||
allowFrom: string[];
|
||||
enabled: boolean;
|
||||
policy: MatrixQaDmPolicy;
|
||||
sessionScope: "per-room" | "per-user";
|
||||
threadReplies: MatrixQaThreadRepliesMode;
|
||||
};
|
||||
encryption: boolean;
|
||||
execApprovals?: MatrixQaExecApprovalsConfigOverrides;
|
||||
configuredBotRoles: MatrixQaActorRole[];
|
||||
groupAllowFrom: string[];
|
||||
groupMentionPatterns: string[];
|
||||
groupPolicy: MatrixQaGroupPolicy;
|
||||
groupsByKey: Record<string, MatrixQaGroupSnapshot>;
|
||||
replyToMode: MatrixQaReplyToMode;
|
||||
startupVerification?: "if-unverified" | "off";
|
||||
streaming: MatrixQaStreamingMode;
|
||||
streamingPreviewToolProgress: boolean;
|
||||
textChunkLimit?: number;
|
||||
threadBindings: MatrixQaThreadBindingsConfigOverrides;
|
||||
threadReplies: MatrixQaThreadRepliesMode;
|
||||
};
|
||||
|
||||
type MatrixQaGroupSnapshot = {
|
||||
allowBots?: MatrixQaAllowBotsMode;
|
||||
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;
|
||||
};
|
||||
|
||||
type MatrixQaAccountExecApprovalsConfig = {
|
||||
agentFilter?: string[];
|
||||
approvers?: string[];
|
||||
enabled?: MatrixQaExecApprovalsEnabled;
|
||||
sessionFilter?: string[];
|
||||
target?: MatrixQaExecApprovalTarget;
|
||||
};
|
||||
|
||||
function normalizeMatrixQaAllowlist(entries?: string[]) {
|
||||
return uniqueStrings(normalizeStringEntries(entries ?? []));
|
||||
}
|
||||
|
||||
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,
|
||||
...(override && Object.hasOwn(override, "allowBots")
|
||||
? { allowBots: override.allowBots }
|
||||
: {}),
|
||||
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,
|
||||
{
|
||||
...(group.allowBots !== undefined ? { allowBots: group.allowBots } : {}),
|
||||
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 = uniqueStrings(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";
|
||||
}
|
||||
if (isMatrixQaStreamingConfig(value)) {
|
||||
if (value.mode === "partial" || value.mode === "quiet") {
|
||||
return value.mode;
|
||||
}
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function isMatrixQaStreamingConfig(
|
||||
value: MatrixQaConfigOverrides["streaming"],
|
||||
): value is MatrixQaStreamingConfig {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function resolveMatrixQaStreamingPreviewToolProgress(
|
||||
value: MatrixQaConfigOverrides["streaming"],
|
||||
): boolean {
|
||||
if (!isMatrixQaStreamingConfig(value)) {
|
||||
return true;
|
||||
}
|
||||
return value.preview?.toolProgress ?? true;
|
||||
}
|
||||
|
||||
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 buildMatrixQaAccountExecApprovalsConfig(
|
||||
overrides?: MatrixQaExecApprovalsConfigOverrides,
|
||||
): MatrixQaAccountExecApprovalsConfig | undefined {
|
||||
if (!overrides) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(overrides.agentFilter ? { agentFilter: overrides.agentFilter } : {}),
|
||||
...(overrides.approvers ? { approvers: normalizeMatrixQaAllowlist(overrides.approvers) } : {}),
|
||||
...(overrides.enabled !== undefined ? { enabled: overrides.enabled } : {}),
|
||||
...(overrides.sessionFilter ? { sessionFilter: overrides.sessionFilter } : {}),
|
||||
...(overrides.target ? { target: overrides.target } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatrixQaConfiguredBotAccounts(params: {
|
||||
driverAccessToken: string | undefined;
|
||||
driverUserId: string;
|
||||
homeserver: string;
|
||||
observerAccessToken: string | undefined;
|
||||
observerUserId: string;
|
||||
roles: MatrixQaActorRole[];
|
||||
}): Record<string, MatrixQaChannelAccountConfig> {
|
||||
const selectedRoles = new Set(params.roles);
|
||||
if (selectedRoles.has("sut")) {
|
||||
throw new Error('Matrix QA configured bot role "sut" would match the SUT account itself');
|
||||
}
|
||||
|
||||
const botSources: Record<
|
||||
Exclude<MatrixQaActorRole, "sut">,
|
||||
{
|
||||
accessToken: string | undefined;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}
|
||||
> = {
|
||||
driver: {
|
||||
accessToken: params.driverAccessToken,
|
||||
accountId: "qa-driver-bot-source",
|
||||
userId: params.driverUserId,
|
||||
},
|
||||
observer: {
|
||||
accessToken: params.observerAccessToken,
|
||||
accountId: "qa-observer-bot-source",
|
||||
userId: params.observerUserId,
|
||||
},
|
||||
};
|
||||
|
||||
const accounts: Record<string, MatrixQaChannelAccountConfig> = {};
|
||||
for (const role of selectedRoles) {
|
||||
if (role !== "driver" && role !== "observer") {
|
||||
continue;
|
||||
}
|
||||
const source = botSources[role];
|
||||
if (!source.accessToken) {
|
||||
throw new Error(`Matrix QA configured bot role "${role}" requires an access token`);
|
||||
}
|
||||
accounts[source.accountId] = {
|
||||
accessToken: source.accessToken,
|
||||
enabled: false,
|
||||
homeserver: params.homeserver,
|
||||
userId: source.userId,
|
||||
};
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
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 chunkModeConfig =
|
||||
params.snapshot.chunkMode !== undefined ? { chunkMode: params.snapshot.chunkMode } : {};
|
||||
const execApprovalsConfig = buildMatrixQaAccountExecApprovalsConfig(
|
||||
params.snapshot.execApprovals,
|
||||
);
|
||||
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 }
|
||||
: {};
|
||||
const textChunkLimitConfig =
|
||||
params.snapshot.textChunkLimit !== undefined
|
||||
? { textChunkLimit: params.snapshot.textChunkLimit }
|
||||
: {};
|
||||
|
||||
return {
|
||||
accessToken: params.sutAccessToken,
|
||||
...(params.sutDeviceId ? { deviceId: params.sutDeviceId } : {}),
|
||||
dm: buildMatrixQaAccountDmConfig({
|
||||
dmOverrides: params.overrides?.dm,
|
||||
snapshot: params.snapshot,
|
||||
}),
|
||||
...(params.snapshot.allowBots !== undefined ? { allowBots: params.snapshot.allowBots } : {}),
|
||||
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,
|
||||
...chunkModeConfig,
|
||||
...(execApprovalsConfig ? { execApprovals: execApprovalsConfig } : {}),
|
||||
...streamingConfig,
|
||||
...textChunkLimitConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatrixQaConfigSnapshot(params: {
|
||||
driverUserId: string;
|
||||
observerUserId: string;
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
sutUserId: string;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
}): MatrixQaConfigSnapshot {
|
||||
return {
|
||||
allowBots: params.overrides?.allowBots,
|
||||
autoJoin: params.overrides?.autoJoin ?? "off",
|
||||
autoJoinAllowlist: resolveMatrixQaAutoJoinAllowlist(params),
|
||||
blockStreaming: params.overrides?.blockStreaming ?? false,
|
||||
chunkMode: params.overrides?.chunkMode,
|
||||
dm: resolveMatrixQaDmConfigSnapshot(params),
|
||||
encryption: params.overrides?.encryption ?? false,
|
||||
execApprovals: params.overrides?.execApprovals,
|
||||
configuredBotRoles: [...(params.overrides?.configuredBotRoles ?? [])],
|
||||
groupAllowFrom: resolveMatrixQaGroupAllowFrom(params),
|
||||
groupMentionPatterns: normalizeMatrixQaAllowlist(params.overrides?.groupMentionPatterns),
|
||||
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),
|
||||
streamingPreviewToolProgress: resolveMatrixQaStreamingPreviewToolProgress(
|
||||
params.overrides?.streaming,
|
||||
),
|
||||
threadBindings: { ...params.overrides?.threadBindings },
|
||||
textChunkLimit: params.overrides?.textChunkLimit,
|
||||
threadReplies: params.overrides?.threadReplies ?? "inbound",
|
||||
approvalForwarding: {
|
||||
exec:
|
||||
params.overrides?.approvalForwarding?.exec ?? params.overrides?.execApprovals !== undefined,
|
||||
plugin: params.overrides?.approvalForwarding?.plugin ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot) {
|
||||
return [
|
||||
`allowBots=${snapshot.allowBots ?? "<default>"}`,
|
||||
`configuredBotRoles=${snapshot.configuredBotRoles.length > 0 ? snapshot.configuredBotRoles.join("|") : "<none>"}`,
|
||||
`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}`,
|
||||
`groupMentionPatterns=${snapshot.groupMentionPatterns.length > 0 ? snapshot.groupMentionPatterns.join("|") : "<default>"}`,
|
||||
`streaming=${snapshot.streaming}`,
|
||||
`streaming.preview.toolProgress=${formatMatrixQaBoolean(snapshot.streamingPreviewToolProgress)}`,
|
||||
`textChunkLimit=${snapshot.textChunkLimit ?? "<default>"}`,
|
||||
`chunkMode=${snapshot.chunkMode ?? "<default>"}`,
|
||||
`execApprovals.enabled=${snapshot.execApprovals?.enabled ?? "<default>"}`,
|
||||
`execApprovals.target=${snapshot.execApprovals?.target ?? "<default>"}`,
|
||||
`blockStreaming=${formatMatrixQaBoolean(snapshot.blockStreaming)}`,
|
||||
`autoJoin=${snapshot.autoJoin}`,
|
||||
`encryption=${formatMatrixQaBoolean(snapshot.encryption)}`,
|
||||
`startupVerification=${snapshot.startupVerification ?? "<default>"}`,
|
||||
`threadBindings.enabled=${snapshot.threadBindings.enabled ?? "<default>"}`,
|
||||
`threadBindings.spawnSessions=${snapshot.threadBindings.spawnSessions ?? "<default>"}`,
|
||||
`approvals.exec.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.exec)}`,
|
||||
`approvals.plugin.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.plugin)}`,
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
export function buildMatrixQaConfig(
|
||||
baseCfg: OpenClawConfig,
|
||||
params: {
|
||||
driverAccessToken?: string;
|
||||
driverUserId: string;
|
||||
homeserver: string;
|
||||
observerAccessToken?: string;
|
||||
observerUserId: string;
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
sutAccessToken: string;
|
||||
sutAccountId: string;
|
||||
sutDeviceId?: string;
|
||||
sutUserId: string;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
},
|
||||
): OpenClawConfig {
|
||||
const pluginAllow = uniqueStrings([...(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);
|
||||
const configuredBotAccounts = buildMatrixQaConfiguredBotAccounts({
|
||||
driverAccessToken: params.driverAccessToken,
|
||||
driverUserId: params.driverUserId,
|
||||
homeserver: params.homeserver,
|
||||
observerAccessToken: params.observerAccessToken,
|
||||
observerUserId: params.observerUserId,
|
||||
roles: snapshot.configuredBotRoles,
|
||||
});
|
||||
const approvalForwardingConfig =
|
||||
snapshot.approvalForwarding.exec || snapshot.approvalForwarding.plugin
|
||||
? {
|
||||
approvals: {
|
||||
...baseCfg.approvals,
|
||||
...(snapshot.approvalForwarding.exec
|
||||
? {
|
||||
exec: {
|
||||
...baseCfg.approvals?.exec,
|
||||
enabled: true,
|
||||
mode: "session" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(snapshot.approvalForwarding.plugin
|
||||
? {
|
||||
plugin: {
|
||||
...baseCfg.approvals?.plugin,
|
||||
enabled: true,
|
||||
mode: "session" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const toolsConfig =
|
||||
params.overrides?.toolProfile || params.overrides?.audio
|
||||
? {
|
||||
...baseCfg.tools,
|
||||
...(params.overrides?.toolProfile
|
||||
? {
|
||||
profile: params.overrides.toolProfile,
|
||||
}
|
||||
: {}),
|
||||
...(params.overrides?.audio
|
||||
? {
|
||||
media: {
|
||||
...baseCfg.tools?.media,
|
||||
audio: {
|
||||
...baseCfg.tools?.media?.audio,
|
||||
...params.overrides.audio,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...baseCfg,
|
||||
...approvalForwardingConfig,
|
||||
...(toolsConfig
|
||||
? {
|
||||
tools: toolsConfig,
|
||||
}
|
||||
: {}),
|
||||
...(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 },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
groupChat: {
|
||||
...baseCfg.messages?.groupChat,
|
||||
...(snapshot.groupMentionPatterns.length > 0
|
||||
? { mentionPatterns: snapshot.groupMentionPatterns }
|
||||
: {}),
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
matrix: {
|
||||
...baseCfg.channels?.matrix,
|
||||
enabled: true,
|
||||
defaultAccount: params.sutAccountId,
|
||||
accounts: {
|
||||
...baseCfg.channels?.matrix?.accounts,
|
||||
...configuredBotAccounts,
|
||||
[params.sutAccountId]: buildMatrixQaChannelAccountConfig({
|
||||
groups,
|
||||
homeserver: params.homeserver,
|
||||
overrides: params.overrides,
|
||||
snapshot,
|
||||
sutAccessToken: params.sutAccessToken,
|
||||
sutDeviceId: params.sutDeviceId,
|
||||
sutUserId: params.sutUserId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Qa Matrix tests prove one differential probe against the Matrix substrate contract.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runMatrixQaDifferentialProbe } from "./differential-probe.js";
|
||||
|
||||
function createProbeFetch(params?: {
|
||||
missingStateErrcode?: string;
|
||||
userId?: string;
|
||||
}): typeof fetch {
|
||||
return async (input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString());
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status,
|
||||
});
|
||||
if (url.pathname.endsWith("/versions")) {
|
||||
return json({ versions: ["v1.11"] });
|
||||
}
|
||||
if (url.pathname.endsWith("/account/whoami")) {
|
||||
return json({ user_id: params?.userId ?? "@probe:matrix.test" });
|
||||
}
|
||||
if (url.pathname.endsWith("/sync")) {
|
||||
return json({ next_batch: url.searchParams.has("since") ? "sync-2" : "sync-1" });
|
||||
}
|
||||
return json({ errcode: params?.missingStateErrcode ?? "M_NOT_FOUND" }, 404);
|
||||
};
|
||||
}
|
||||
|
||||
describe("Matrix QA differential probe", () => {
|
||||
it("runs unchanged against the Matrix substrate contract", async () => {
|
||||
const result = await runMatrixQaDifferentialProbe({
|
||||
accessToken: "token",
|
||||
baseUrl: "http://matrix.test",
|
||||
fetchImpl: createProbeFetch(),
|
||||
roomId: "!probe:matrix.test",
|
||||
userId: "@probe:matrix.test",
|
||||
});
|
||||
|
||||
expect(result.profile).toBe("matrix-qa-v1");
|
||||
expect(result.sync).toEqual({
|
||||
continuity: true,
|
||||
incrementalStatus: 200,
|
||||
initialStatus: 200,
|
||||
});
|
||||
expect(result.steps.map((step) => [step.id, step.status])).toEqual([
|
||||
["versions", 200],
|
||||
["whoami", 200],
|
||||
["sync-initial", 200],
|
||||
["sync-incremental", 200],
|
||||
["missing-state", 404],
|
||||
]);
|
||||
expect(result.steps.at(-1)?.errcode).toBe("M_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("rejects a mismatched whoami identity", async () => {
|
||||
await expect(
|
||||
runMatrixQaDifferentialProbe({
|
||||
accessToken: "token",
|
||||
baseUrl: "http://matrix.test",
|
||||
fetchImpl: createProbeFetch({ userId: "@other:matrix.test" }),
|
||||
roomId: "!probe:matrix.test",
|
||||
userId: "@probe:matrix.test",
|
||||
}),
|
||||
).rejects.toThrow("unexpected user_id");
|
||||
});
|
||||
|
||||
it("rejects a missing-state response with the wrong Matrix errcode", async () => {
|
||||
await expect(
|
||||
runMatrixQaDifferentialProbe({
|
||||
accessToken: "token",
|
||||
baseUrl: "http://matrix.test",
|
||||
fetchImpl: createProbeFetch({ missingStateErrcode: "M_FORBIDDEN" }),
|
||||
roomId: "!probe:matrix.test",
|
||||
userId: "@probe:matrix.test",
|
||||
}),
|
||||
).rejects.toThrow("did not return M_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
174
extensions/qa-matrix/src/substrate/differential-probe.ts
Normal file
174
extensions/qa-matrix/src/substrate/differential-probe.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { normalizeMatrixQaRoute } from "./recording-proxy.js";
|
||||
// Qa Matrix plugin module probes Matrix substrates through one protocol contract.
|
||||
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
|
||||
|
||||
const MATRIX_QA_DIFFERENTIAL_PROFILE = "matrix-qa-v1";
|
||||
|
||||
type MatrixQaProbeStep = {
|
||||
errcode?: string;
|
||||
id: string;
|
||||
method: "GET";
|
||||
responseFields: string[];
|
||||
route: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type MatrixQaDifferentialProbeResult = {
|
||||
profile: typeof MATRIX_QA_DIFFERENTIAL_PROFILE;
|
||||
steps: MatrixQaProbeStep[];
|
||||
sync: {
|
||||
continuity: boolean;
|
||||
incrementalStatus: number;
|
||||
initialStatus: number;
|
||||
};
|
||||
};
|
||||
|
||||
function topLevelFields(value: unknown) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? Object.keys(value).toSorted()
|
||||
: [];
|
||||
}
|
||||
|
||||
function errcode(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (value as { errcode?: unknown }).errcode;
|
||||
return typeof candidate === "string" ? candidate : undefined;
|
||||
}
|
||||
|
||||
function nextBatch(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (value as { next_batch?: unknown }).next_batch;
|
||||
return typeof candidate === "string" ? candidate : undefined;
|
||||
}
|
||||
|
||||
function userId(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (value as { user_id?: unknown }).user_id;
|
||||
return typeof candidate === "string" ? candidate : undefined;
|
||||
}
|
||||
|
||||
function buildStep(params: {
|
||||
body: unknown;
|
||||
endpoint: string;
|
||||
id: string;
|
||||
status: number;
|
||||
}): MatrixQaProbeStep {
|
||||
const code = errcode(params.body);
|
||||
return {
|
||||
...(code ? { errcode: code } : {}),
|
||||
id: params.id,
|
||||
method: "GET",
|
||||
responseFields: topLevelFields(params.body),
|
||||
route: normalizeMatrixQaRoute(new URL(params.endpoint, "http://matrix.test").pathname),
|
||||
status: params.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMatrixQaDifferentialProbe(params: {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
fetchImpl?: MatrixQaFetchLike;
|
||||
roomId: string;
|
||||
userId: string;
|
||||
}): Promise<MatrixQaDifferentialProbeResult> {
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
const versions = await requestMatrixJson<Record<string, unknown>>({
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "/_matrix/client/versions",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
const whoami = await requestMatrixJson<Record<string, unknown>>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
if (userId(whoami.body) !== params.userId) {
|
||||
throw new Error("Matrix differential probe whoami returned an unexpected user_id");
|
||||
}
|
||||
const initialSync = await requestMatrixJson<Record<string, unknown>>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
query: { timeout: 0 },
|
||||
});
|
||||
const initialToken = nextBatch(initialSync.body);
|
||||
if (!initialToken) {
|
||||
throw new Error("Matrix differential probe initial sync did not return next_batch");
|
||||
}
|
||||
const incrementalSync = await requestMatrixJson<Record<string, unknown>>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
query: { since: initialToken, timeout: 0 },
|
||||
});
|
||||
const incrementalToken = nextBatch(incrementalSync.body);
|
||||
if (!incrementalToken) {
|
||||
throw new Error("Matrix differential probe incremental sync did not return next_batch");
|
||||
}
|
||||
const missingStateEndpoint = `/_matrix/client/v3/rooms/${encodeURIComponent(params.roomId)}/state/org.openclaw.qa.missing/${encodeURIComponent(params.userId)}`;
|
||||
const missingState = await requestMatrixJson<Record<string, unknown>>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
endpoint: missingStateEndpoint,
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
okStatuses: [404],
|
||||
});
|
||||
if (errcode(missingState.body) !== "M_NOT_FOUND") {
|
||||
throw new Error("Matrix differential probe missing state did not return M_NOT_FOUND");
|
||||
}
|
||||
|
||||
return {
|
||||
profile: MATRIX_QA_DIFFERENTIAL_PROFILE,
|
||||
steps: [
|
||||
buildStep({
|
||||
body: versions.body,
|
||||
endpoint: "/_matrix/client/versions",
|
||||
id: "versions",
|
||||
status: versions.status,
|
||||
}),
|
||||
buildStep({
|
||||
body: whoami.body,
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
id: "whoami",
|
||||
status: whoami.status,
|
||||
}),
|
||||
buildStep({
|
||||
body: initialSync.body,
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
id: "sync-initial",
|
||||
status: initialSync.status,
|
||||
}),
|
||||
buildStep({
|
||||
body: incrementalSync.body,
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
id: "sync-incremental",
|
||||
status: incrementalSync.status,
|
||||
}),
|
||||
buildStep({
|
||||
body: missingState.body,
|
||||
endpoint: missingStateEndpoint,
|
||||
id: "missing-state",
|
||||
status: missingState.status,
|
||||
}),
|
||||
],
|
||||
sync: {
|
||||
continuity: Boolean(initialToken && incrementalToken),
|
||||
incrementalStatus: incrementalSync.status,
|
||||
initialStatus: initialSync.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
76
extensions/qa-matrix/src/substrate/e2ee-client.test.ts
Normal file
76
extensions/qa-matrix/src/substrate/e2ee-client.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Qa Matrix tests cover e2ee client plugin behavior.
|
||||
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 and sync state by actor account", () => {
|
||||
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(first.accountDir, "sync-store.json"));
|
||||
expect(second.storagePath).toBe(first.storagePath);
|
||||
});
|
||||
|
||||
it("records late-decrypted payload updates for an existing event id", () => {
|
||||
const previous = {
|
||||
eventId: "$reply",
|
||||
kind: "message" as const,
|
||||
roomId: "!room:matrix-qa.test",
|
||||
sender: "@bot:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
};
|
||||
|
||||
expect(
|
||||
testing.shouldRecordMatrixQaObservedEventUpdate({
|
||||
previous,
|
||||
next: {
|
||||
...previous,
|
||||
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
testing.shouldRecordMatrixQaObservedEventUpdate({
|
||||
previous: {
|
||||
...previous,
|
||||
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
next: {
|
||||
...previous,
|
||||
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
588
extensions/qa-matrix/src/substrate/e2ee-client.ts
Normal file
588
extensions/qa-matrix/src/substrate/e2ee-client.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
// Qa Matrix plugin module implements e2ee client behavior.
|
||||
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 type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateEntry,
|
||||
PluginStateKeyedStore,
|
||||
PluginStateSyncKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { buildMatrixQaMessageContent } from "./client.js";
|
||||
import { findMatrixQaObservedEventMatch, normalizeMatrixQaObservedEvent } from "./events.js";
|
||||
import type { MatrixQaObservedEvent } from "./events.js";
|
||||
import type { MatrixQaRoomEventWaitResult } from "./sync.js";
|
||||
|
||||
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}` | `cli-${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"] },
|
||||
},
|
||||
};
|
||||
|
||||
type MatrixQaPluginStateValue = {
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
const matrixQaPluginStateNamespaces = new Map<string, Map<string, MatrixQaPluginStateValue>>();
|
||||
|
||||
function resolveMatrixQaPluginStateNamespaceKey(options: OpenKeyedStoreOptions): string {
|
||||
return `${options.env?.OPENCLAW_STATE_DIR ?? ""}\0${options.namespace}`;
|
||||
}
|
||||
|
||||
function resolveMatrixQaPluginStateRows(
|
||||
options: OpenKeyedStoreOptions,
|
||||
): Map<string, MatrixQaPluginStateValue> {
|
||||
const namespaceKey = resolveMatrixQaPluginStateNamespaceKey(options);
|
||||
let rows = matrixQaPluginStateNamespaces.get(namespaceKey);
|
||||
if (!rows) {
|
||||
rows = new Map();
|
||||
matrixQaPluginStateNamespaces.set(namespaceKey, rows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function pruneMatrixQaExpiredPluginState(rows: Map<string, MatrixQaPluginStateValue>): void {
|
||||
const now = Date.now();
|
||||
for (const [key, row] of rows) {
|
||||
if (row.expiresAt !== undefined && row.expiresAt <= now) {
|
||||
rows.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceMatrixQaPluginStateLimit(
|
||||
rows: Map<string, MatrixQaPluginStateValue>,
|
||||
maxEntries: number,
|
||||
nextKey: string,
|
||||
): void {
|
||||
if (rows.has(nextKey)) {
|
||||
return;
|
||||
}
|
||||
while (rows.size >= maxEntries) {
|
||||
const oldest = [...rows.entries()].toSorted(
|
||||
(a, b) => a[1].createdAt - b[1].createdAt || a[0].localeCompare(b[0]),
|
||||
)[0]?.[0];
|
||||
if (!oldest) {
|
||||
return;
|
||||
}
|
||||
rows.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function createMatrixQaPluginStateSyncKeyedStore<T>(
|
||||
options: OpenKeyedStoreOptions,
|
||||
): PluginStateSyncKeyedStore<T> {
|
||||
const rows = resolveMatrixQaPluginStateRows(options);
|
||||
const resolveExpiresAt = (ttlMs?: number) => {
|
||||
const effectiveTtlMs = ttlMs ?? options.defaultTtlMs;
|
||||
return effectiveTtlMs === undefined ? undefined : Date.now() + effectiveTtlMs;
|
||||
};
|
||||
const register = (key: string, value: T, opts?: { ttlMs?: number }) => {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
enforceMatrixQaPluginStateLimit(rows, options.maxEntries, key);
|
||||
rows.set(key, {
|
||||
createdAt: rows.get(key)?.createdAt ?? Date.now(),
|
||||
expiresAt: resolveExpiresAt(opts?.ttlMs),
|
||||
value,
|
||||
});
|
||||
};
|
||||
return {
|
||||
register,
|
||||
registerIfAbsent(key, value, opts) {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
if (rows.has(key)) {
|
||||
return false;
|
||||
}
|
||||
register(key, value, opts);
|
||||
return true;
|
||||
},
|
||||
update(key, updateValue, opts) {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
const next = updateValue(rows.get(key)?.value as T | undefined);
|
||||
if (next === undefined) {
|
||||
return false;
|
||||
}
|
||||
register(key, next, opts);
|
||||
return true;
|
||||
},
|
||||
lookup(key) {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
return rows.get(key)?.value as T | undefined;
|
||||
},
|
||||
consume(key) {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
const value = rows.get(key)?.value as T | undefined;
|
||||
rows.delete(key);
|
||||
return value;
|
||||
},
|
||||
delete(key) {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
return rows.delete(key);
|
||||
},
|
||||
entries() {
|
||||
pruneMatrixQaExpiredPluginState(rows);
|
||||
return [...rows.entries()].map(([key, row]): PluginStateEntry<T> => {
|
||||
const entry: PluginStateEntry<T> = {
|
||||
key,
|
||||
value: row.value as T,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
if (row.expiresAt !== undefined) {
|
||||
entry.expiresAt = row.expiresAt;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
rows.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMatrixQaPluginStateKeyedStore<T>(
|
||||
options: OpenKeyedStoreOptions,
|
||||
): PluginStateKeyedStore<T> {
|
||||
const syncStore = createMatrixQaPluginStateSyncKeyedStore<T>(options);
|
||||
return {
|
||||
register: async (...args) => syncStore.register(...args),
|
||||
registerIfAbsent: async (...args) => syncStore.registerIfAbsent(...args),
|
||||
update: async (...args) => syncStore.update?.(...args) ?? false,
|
||||
lookup: async (...args) => syncStore.lookup(...args),
|
||||
consume: async (...args) => syncStore.consume(...args),
|
||||
delete: async (...args) => syncStore.delete(...args),
|
||||
entries: async () => syncStore.entries(),
|
||||
clear: async () => syncStore.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRecordMatrixQaObservedEventUpdate(params: {
|
||||
next: MatrixQaObservedEvent;
|
||||
previous: MatrixQaObservedEvent | undefined;
|
||||
}) {
|
||||
const previous = params.previous;
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
const next = params.next;
|
||||
return (
|
||||
(previous.body === undefined && next.body !== undefined) ||
|
||||
(previous.formattedBody === undefined && next.formattedBody !== undefined) ||
|
||||
(previous.msgtype === undefined && next.msgtype !== undefined) ||
|
||||
(previous.mentions === undefined && next.mentions !== undefined) ||
|
||||
(previous.attachment === undefined && next.attachment !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export type MatrixQaE2eeScenarioClient = {
|
||||
acceptVerification(id: string): Promise<MatrixVerificationSummary>;
|
||||
bootstrapOwnDeviceVerification(params?: {
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
forceResetCrossSigning?: boolean;
|
||||
recoveryKey?: string;
|
||||
verifyOwnIdentity?: boolean;
|
||||
}): 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(params?: {
|
||||
rotateRecoveryKey?: boolean;
|
||||
}): 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>;
|
||||
waitForJoinedMember(params: { roomId: string; timeoutMs: number; userId: string }): Promise<void>;
|
||||
waitForRoomEvent(params: {
|
||||
predicate: (event: MatrixQaObservedEvent) => boolean;
|
||||
roomId: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<{
|
||||
event: MatrixQaObservedEvent;
|
||||
since?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export 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 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(accountDir, "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,
|
||||
});
|
||||
runtime.setMatrixRuntime({
|
||||
config: {
|
||||
current: () => ({}),
|
||||
mutateConfigFile: async () => ({}),
|
||||
replaceConfigFile: async () => ({}),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: () => params.outputDir,
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
|
||||
createMatrixQaPluginStateKeyedStore<T>(options),
|
||||
openSyncKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
|
||||
createMatrixQaPluginStateSyncKeyedStore<T>(options),
|
||||
},
|
||||
} as never);
|
||||
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 },
|
||||
storageRootDir: path.dirname(storage.storagePath),
|
||||
syncFilter: MATRIX_QA_E2EE_SYNC_FILTER,
|
||||
userId: params.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMatrixQaE2eeScenarioClient(
|
||||
params: MatrixQaE2eeClientParams & {
|
||||
observedEvents: MatrixQaObservedEvent[];
|
||||
},
|
||||
): Promise<MatrixQaE2eeScenarioClient> {
|
||||
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
|
||||
const localEvents: MatrixQaObservedEvent[] = [];
|
||||
const verificationSummaries: MatrixVerificationSummary[] = [];
|
||||
const observedEventsById = new Map<string, MatrixQaObservedEvent>();
|
||||
let cursorIndex = 0;
|
||||
|
||||
const recordEvent = (roomId: string, event: MatrixRawEvent) => {
|
||||
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
|
||||
if (
|
||||
!normalized ||
|
||||
!shouldRecordMatrixQaObservedEventUpdate({
|
||||
next: normalized,
|
||||
previous: observedEventsById.get(normalized.eventId),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
observedEventsById.set(normalized.eventId, normalized);
|
||||
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 waitForJoinedMember(opts) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < opts.timeoutMs) {
|
||||
if (client.hasSyncedJoinedRoomMember(opts.roomId, opts.userId)) {
|
||||
return;
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, opts.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`Matrix E2EE client did not sync joined membership for ${opts.userId} in ${opts.roomId}`,
|
||||
);
|
||||
},
|
||||
async requestVerification(opts) {
|
||||
return await requireCrypto().requestVerification(opts);
|
||||
},
|
||||
async resetRoomKeyBackup(paramsLocal) {
|
||||
return await client.resetRoomKeyBackup(paramsLocal);
|
||||
},
|
||||
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,
|
||||
shouldRecordMatrixQaObservedEventUpdate,
|
||||
};
|
||||
export { testing as __testing };
|
||||
355
extensions/qa-matrix/src/substrate/events.test.ts
Normal file
355
extensions/qa-matrix/src/substrate/events.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
// Qa Matrix tests cover events plugin behavior.
|
||||
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({
|
||||
kind: "notice",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$notice",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "notice",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.notice",
|
||||
membership: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$replace",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "finalized",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
relatesTo: {
|
||||
eventId: "$draft",
|
||||
inReplyToId: undefined,
|
||||
isFallingBack: undefined,
|
||||
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("summarizes Matrix approval metadata without dumping full command text", () => {
|
||||
const commandText = `printf ${"A".repeat(300)}`;
|
||||
expect(
|
||||
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
|
||||
event_id: "$approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "React here: ✅ Allow once, ❌ Deny",
|
||||
msgtype: "m.text",
|
||||
"com.openclaw.approval": {
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
commandText,
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
state: "pending",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "React here: ✅ Allow once, ❌ Deny",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
approval: {
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
commandTextPreview: commandText.slice(0, 160),
|
||||
hasCommandText: true,
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
state: "pending",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes Matrix plugin approval metadata fields", () => {
|
||||
expect(
|
||||
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
|
||||
event_id: "$plugin-approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "Plugin approval required",
|
||||
msgtype: "m.text",
|
||||
"com.openclaw.approval": {
|
||||
agentId: "qa",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
id: "plugin:approval-1",
|
||||
kind: "plugin",
|
||||
pluginId: "qa-plugin",
|
||||
severity: "medium",
|
||||
state: "pending",
|
||||
toolName: "qa_tool",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$plugin-approval",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "Plugin approval required",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
approval: {
|
||||
agentId: "qa",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
id: "plugin:approval-1",
|
||||
kind: "plugin",
|
||||
pluginId: "qa-plugin",
|
||||
severity: "medium",
|
||||
state: "pending",
|
||||
toolName: "qa_tool",
|
||||
type: "approval.request",
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$image",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "Protocol note: generated the QA lighthouse image successfully.",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.image",
|
||||
membership: undefined,
|
||||
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({
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$image",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "qa-lighthouse.png",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.image",
|
||||
membership: undefined,
|
||||
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({
|
||||
kind: "membership",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$membership",
|
||||
sender: "@driver:matrix-qa.test",
|
||||
stateKey: "@sut:matrix-qa.test",
|
||||
type: "m.room.member",
|
||||
originServerTs: undefined,
|
||||
body: undefined,
|
||||
formattedBody: undefined,
|
||||
msgtype: undefined,
|
||||
membership: "leave",
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
kind: "redaction",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$redaction",
|
||||
sender: "@driver:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.redaction",
|
||||
originServerTs: undefined,
|
||||
body: undefined,
|
||||
formattedBody: undefined,
|
||||
msgtype: undefined,
|
||||
membership: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
319
extensions/qa-matrix/src/substrate/events.ts
Normal file
319
extensions/qa-matrix/src/substrate/events.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Qa Matrix plugin module implements events behavior.
|
||||
export type MatrixQaRoomEvent = {
|
||||
content?: Record<string, unknown>;
|
||||
event_id?: string;
|
||||
origin_server_ts?: number;
|
||||
sender?: string;
|
||||
state_key?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type MatrixQaObservedEventKind =
|
||||
| "membership"
|
||||
| "message"
|
||||
| "notice"
|
||||
| "redaction"
|
||||
| "reaction"
|
||||
| "room-event";
|
||||
|
||||
type MatrixQaObservedEventAttachment = {
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
kind: "audio" | "file" | "image" | "sticker" | "video";
|
||||
};
|
||||
|
||||
type MatrixQaObservedApproval = {
|
||||
agentId?: string;
|
||||
allowedDecisions?: string[];
|
||||
commandTextPreview?: string;
|
||||
hasCommandText?: boolean;
|
||||
id: string;
|
||||
kind: "exec" | "plugin";
|
||||
pluginId?: string;
|
||||
severity?: string;
|
||||
state?: string;
|
||||
toolName?: string;
|
||||
type?: string;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
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;
|
||||
approval?: MatrixQaObservedApproval;
|
||||
};
|
||||
|
||||
const MATRIX_QA_APPROVAL_METADATA_KEY = "com.openclaw.approval";
|
||||
const MATRIX_QA_APPROVAL_COMMAND_PREVIEW_CHARS = 160;
|
||||
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatrixQaApprovalAllowedDecisions(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeMatrixQaApprovalMetadata(value: unknown): MatrixQaObservedApproval | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata = value as Record<string, unknown>;
|
||||
const id = typeof metadata.id === "string" ? metadata.id.trim() : "";
|
||||
const kind = metadata.kind;
|
||||
if (!id || (kind !== "exec" && kind !== "plugin")) {
|
||||
return undefined;
|
||||
}
|
||||
const commandText =
|
||||
typeof metadata.commandText === "string" ? metadata.commandText.trim() : undefined;
|
||||
const commandPreview =
|
||||
typeof metadata.commandPreview === "string" ? metadata.commandPreview.trim() : undefined;
|
||||
const commandTextPreview = (commandPreview || commandText)?.slice(
|
||||
0,
|
||||
MATRIX_QA_APPROVAL_COMMAND_PREVIEW_CHARS,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
...(typeof metadata.agentId === "string" ? { agentId: metadata.agentId } : {}),
|
||||
...(typeof metadata.state === "string" ? { state: metadata.state } : {}),
|
||||
...(typeof metadata.type === "string" ? { type: metadata.type } : {}),
|
||||
...(typeof metadata.version === "number" ? { version: metadata.version } : {}),
|
||||
...(metadata.allowedDecisions
|
||||
? { allowedDecisions: normalizeMatrixQaApprovalAllowedDecisions(metadata.allowedDecisions) }
|
||||
: {}),
|
||||
...(commandText ? { hasCommandText: true } : {}),
|
||||
...(commandTextPreview ? { commandTextPreview } : {}),
|
||||
...(kind === "plugin" && typeof metadata.pluginId === "string"
|
||||
? { pluginId: metadata.pluginId }
|
||||
: {}),
|
||||
...(kind === "plugin" && typeof metadata.severity === "string"
|
||||
? { severity: metadata.severity }
|
||||
: {}),
|
||||
...(kind === "plugin" && typeof metadata.toolName === "string"
|
||||
? { toolName: metadata.toolName }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const approval = normalizeMatrixQaApprovalMetadata(
|
||||
messageContent[MATRIX_QA_APPROVAL_METADATA_KEY] ?? content[MATRIX_QA_APPROVAL_METADATA_KEY],
|
||||
);
|
||||
|
||||
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 } : {}),
|
||||
...(approval ? { approval } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
241
extensions/qa-matrix/src/substrate/fault-proxy.test.ts
Normal file
241
extensions/qa-matrix/src/substrate/fault-proxy.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Qa Matrix tests cover fault proxy plugin behavior.
|
||||
import { createServer } from "node:http";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startMatrixQaFaultProxy, type MatrixQaFaultProxy } from "./fault-proxy.js";
|
||||
|
||||
const servers: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
async function startTargetServer(params?: {
|
||||
responseBody?: Buffer | string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const requests: Array<{
|
||||
authorization?: string;
|
||||
body: string;
|
||||
method: string;
|
||||
url: string;
|
||||
}> = [];
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
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", ...params?.responseHeaders });
|
||||
res.end(params?.responseBody ?? 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips stale content-encoding after buffering decoded bodies", async () => {
|
||||
const body = Buffer.from(JSON.stringify({ forwarded: true }));
|
||||
const target = await startTargetServer({
|
||||
responseBody: gzipSync(body),
|
||||
responseHeaders: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": String(gzipSync(body).byteLength),
|
||||
},
|
||||
});
|
||||
proxy = await startMatrixQaFaultProxy({ targetBaseUrl: target.baseUrl, rules: [] });
|
||||
|
||||
const response = await fetch(`${proxy.baseUrl}/encoded`);
|
||||
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
await expect(response.json()).resolves.toEqual({ forwarded: true });
|
||||
});
|
||||
|
||||
it("mutates matching forwarded Matrix responses", async () => {
|
||||
const target = await startTargetServer();
|
||||
proxy = await startMatrixQaFaultProxy({
|
||||
targetBaseUrl: target.baseUrl,
|
||||
rules: [
|
||||
{
|
||||
id: "sync-state-after",
|
||||
match: (request) =>
|
||||
request.method === "GET" &&
|
||||
request.path === "/_matrix/client/v3/sync" &&
|
||||
request.search.includes("org.matrix.msc4222.use_state_after=true"),
|
||||
mutateResponse: ({ response }) => ({
|
||||
...response,
|
||||
body: Buffer.from(JSON.stringify({ forwarded: true, mutated: true })),
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mutated = await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true`,
|
||||
{
|
||||
headers: { authorization: "Bearer driver-token" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(mutated.status).toBe(200);
|
||||
await expect(mutated.json()).resolves.toEqual({ forwarded: true, mutated: true });
|
||||
expect(proxy.hits()).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/_matrix/client/v3/sync",
|
||||
ruleId: "sync-state-after",
|
||||
},
|
||||
]);
|
||||
expect(target.requests).toEqual([
|
||||
{
|
||||
authorization: "Bearer driver-token",
|
||||
body: "",
|
||||
method: "GET",
|
||||
url: "/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects oversized forwarded request bodies before contacting the target", async () => {
|
||||
const target = await startTargetServer();
|
||||
proxy = await startMatrixQaFaultProxy({
|
||||
maxRequestBytes: 4,
|
||||
targetBaseUrl: target.baseUrl,
|
||||
rules: [],
|
||||
});
|
||||
|
||||
const rejected = await fetch(`${proxy.baseUrl}/_matrix/client/v3/send`, {
|
||||
body: "12345",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(rejected.status).toBe(413);
|
||||
expect(rejected.headers.get("connection")).toBe("close");
|
||||
await expect(rejected.json()).resolves.toMatchObject({
|
||||
errcode: "MATRIX_QA_FAULT_PROXY_REQUEST_TOO_LARGE",
|
||||
});
|
||||
expect(target.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects oversized forwarded Matrix responses without buffering the full body", async () => {
|
||||
const target = await startTargetServer({ responseBody: JSON.stringify({ payload: "large" }) });
|
||||
proxy = await startMatrixQaFaultProxy({
|
||||
maxResponseBytes: 8,
|
||||
targetBaseUrl: target.baseUrl,
|
||||
rules: [],
|
||||
});
|
||||
|
||||
const rejected = await fetch(`${proxy.baseUrl}/_matrix/client/v3/sync`);
|
||||
|
||||
expect(rejected.status).toBe(502);
|
||||
await expect(rejected.json()).resolves.toMatchObject({
|
||||
errcode: "MATRIX_QA_FAULT_PROXY_RESPONSE_TOO_LARGE",
|
||||
});
|
||||
expect(target.requests).toEqual([
|
||||
{
|
||||
body: "",
|
||||
method: "GET",
|
||||
url: "/_matrix/client/v3/sync",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
451
extensions/qa-matrix/src/substrate/fault-proxy.ts
Normal file
451
extensions/qa-matrix/src/substrate/fault-proxy.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
// Qa Matrix plugin module implements fault proxy behavior.
|
||||
import {
|
||||
createServer,
|
||||
type IncomingHttpHeaders,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
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;
|
||||
body: Buffer;
|
||||
headers: IncomingHttpHeaders;
|
||||
method: string;
|
||||
path: string;
|
||||
search: string;
|
||||
};
|
||||
|
||||
type MatrixQaFaultProxyResponse = {
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type MatrixQaFaultProxyForwardedResponse = {
|
||||
body: Buffer;
|
||||
headers: Headers;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type MatrixQaFaultProxyExchange = {
|
||||
context?: unknown;
|
||||
request: MatrixQaFaultProxyRequest;
|
||||
response: MatrixQaFaultProxyForwardedResponse;
|
||||
};
|
||||
|
||||
export type MatrixQaFaultProxyObserver = {
|
||||
createExchangeContext?: (request: MatrixQaFaultProxyRequest) => unknown;
|
||||
onExchange?: (exchange: MatrixQaFaultProxyExchange) => Promise<void> | void;
|
||||
};
|
||||
|
||||
class MatrixQaFaultProxyHttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MatrixQaFaultProxyHttpError";
|
||||
}
|
||||
}
|
||||
|
||||
export type MatrixQaFaultProxyRule = {
|
||||
id: string;
|
||||
match(request: MatrixQaFaultProxyRequest): boolean;
|
||||
mutateResponse?(params: {
|
||||
request: MatrixQaFaultProxyRequest;
|
||||
response: MatrixQaFaultProxyForwardedResponse;
|
||||
}): MatrixQaFaultProxyForwardedResponse | Promise<MatrixQaFaultProxyForwardedResponse>;
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeByteChunk(chunk: string | Buffer): Buffer {
|
||||
return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
}
|
||||
|
||||
function rejectOversizedRequestBody(maxBytes: number, size: number) {
|
||||
return new MatrixQaFaultProxyHttpError(
|
||||
413,
|
||||
"MATRIX_QA_FAULT_PROXY_REQUEST_TOO_LARGE",
|
||||
`Matrix QA fault proxy request body exceeds ${maxBytes} bytes (got at least ${size})`,
|
||||
);
|
||||
}
|
||||
|
||||
function rejectAbortedRequestBody() {
|
||||
return new MatrixQaFaultProxyHttpError(
|
||||
400,
|
||||
"MATRIX_QA_FAULT_PROXY_REQUEST_ABORTED",
|
||||
"Matrix QA fault proxy request body ended before upload completed",
|
||||
);
|
||||
}
|
||||
|
||||
function drainRejectedRequestBody(req: IncomingMessage) {
|
||||
const onError = () => undefined;
|
||||
const onClose = () => {
|
||||
req.off("error", onError);
|
||||
};
|
||||
req.on("error", onError);
|
||||
req.once("close", onClose);
|
||||
req.resume();
|
||||
}
|
||||
|
||||
async function readRequestBody(req: IncomingMessage, maxBytes: number) {
|
||||
const contentLength = normalizeHeaderValue(req.headers["content-length"]);
|
||||
if (contentLength !== undefined) {
|
||||
const size = Number(contentLength);
|
||||
if (Number.isFinite(size) && size > maxBytes) {
|
||||
drainRejectedRequestBody(req);
|
||||
throw rejectOversizedRequestBody(maxBytes, size);
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
req.off("data", onData);
|
||||
req.off("end", onEnd);
|
||||
req.off("error", onError);
|
||||
req.off("aborted", onAborted);
|
||||
req.off("close", onClose);
|
||||
};
|
||||
const stopReading = () => {
|
||||
req.off("data", onData);
|
||||
req.off("end", onEnd);
|
||||
req.off("aborted", onAborted);
|
||||
};
|
||||
const settleReject = (error: Error, options?: { drain?: boolean }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (options?.drain) {
|
||||
stopReading();
|
||||
req.resume();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const onData = (chunk: string | Buffer) => {
|
||||
const buffer = normalizeByteChunk(chunk);
|
||||
const nextTotal = total + buffer.byteLength;
|
||||
if (nextTotal > maxBytes) {
|
||||
settleReject(rejectOversizedRequestBody(maxBytes, nextTotal), { drain: true });
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
total = nextTotal;
|
||||
};
|
||||
const onEnd = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(Buffer.concat(chunks, total));
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
if (settled) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
settleReject(error);
|
||||
};
|
||||
const onAborted = () => {
|
||||
settleReject(rejectAbortedRequestBody());
|
||||
};
|
||||
const onClose = () => {
|
||||
if (settled) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
if (!req.complete) {
|
||||
settleReject(rejectAbortedRequestBody());
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
req.on("data", onData);
|
||||
req.once("end", onEnd);
|
||||
req.once("error", onError);
|
||||
req.once("aborted", onAborted);
|
||||
req.once("close", onClose);
|
||||
});
|
||||
}
|
||||
|
||||
function bufferToArrayBuffer(buffer: Buffer) {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function normalizeJsonResponse(
|
||||
response: MatrixQaFaultProxyResponse,
|
||||
): MatrixQaFaultProxyForwardedResponse {
|
||||
const body =
|
||||
response.body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(response.body));
|
||||
return {
|
||||
body,
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
...response.headers,
|
||||
}),
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function forwardMatrixQaFaultProxyRequest(params: {
|
||||
body: Buffer;
|
||||
maxResponseBytes: number;
|
||||
req: IncomingMessage;
|
||||
targetUrl: URL;
|
||||
}): Promise<MatrixQaFaultProxyForwardedResponse> {
|
||||
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: await readResponseWithLimit(response, params.maxResponseBytes, {
|
||||
onOverflow: ({ size }) =>
|
||||
new MatrixQaFaultProxyHttpError(
|
||||
502,
|
||||
"MATRIX_QA_FAULT_PROXY_RESPONSE_TOO_LARGE",
|
||||
`Matrix QA fault proxy upstream response exceeds ${params.maxResponseBytes} bytes (got at least ${size})`,
|
||||
),
|
||||
}),
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function writeForwardedResponse(
|
||||
res: ServerResponse,
|
||||
response: MatrixQaFaultProxyForwardedResponse,
|
||||
options: { preserveConnectionClose?: boolean } = {},
|
||||
) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of response.headers) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const isIntentionalConnectionClose =
|
||||
options.preserveConnectionClose && normalizedKey === "connection" && value === "close";
|
||||
if (
|
||||
(!HOP_BY_HOP_HEADERS.has(normalizedKey) || isIntentionalConnectionClose) &&
|
||||
normalizedKey !== "content-encoding" &&
|
||||
normalizedKey !== "content-length"
|
||||
) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
res.writeHead(response.status, headers);
|
||||
res.end(response.body);
|
||||
}
|
||||
|
||||
export async function startMatrixQaFaultProxy(
|
||||
params: MatrixQaFaultProxyObserver & {
|
||||
maxRequestBytes?: number;
|
||||
maxResponseBytes?: number;
|
||||
rules: MatrixQaFaultProxyRule[];
|
||||
targetBaseUrl: string;
|
||||
},
|
||||
): Promise<MatrixQaFaultProxy> {
|
||||
const targetBaseUrl = new URL(params.targetBaseUrl);
|
||||
const maxRequestBytes = params.maxRequestBytes ?? DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES;
|
||||
const maxResponseBytes = params.maxResponseBytes ?? DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES;
|
||||
const hits: MatrixQaFaultProxyHit[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
let observedRequest: MatrixQaFaultProxyRequest | undefined;
|
||||
let observedContext: unknown;
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
|
||||
const path = requestUrl.pathname;
|
||||
const bearerToken = extractBearerToken(req.headers);
|
||||
const body = await readRequestBody(req, maxRequestBytes);
|
||||
const request: MatrixQaFaultProxyRequest = {
|
||||
...(bearerToken ? { bearerToken } : {}),
|
||||
body,
|
||||
headers: req.headers,
|
||||
method: req.method ?? "GET",
|
||||
path,
|
||||
search: requestUrl.search,
|
||||
};
|
||||
observedRequest = request;
|
||||
const context = params.createExchangeContext?.(request);
|
||||
observedContext = context;
|
||||
const rule = params.rules.find((candidate) => candidate.match(request));
|
||||
if (rule) {
|
||||
hits.push({
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
ruleId: rule.id,
|
||||
});
|
||||
if (rule.response) {
|
||||
const response = normalizeJsonResponse(rule.response(request));
|
||||
await params.onExchange?.({
|
||||
...(context !== undefined ? { context } : {}),
|
||||
request,
|
||||
response,
|
||||
});
|
||||
writeForwardedResponse(res, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const forwarded = await forwardMatrixQaFaultProxyRequest({
|
||||
body,
|
||||
maxResponseBytes,
|
||||
req,
|
||||
targetUrl: requestUrl,
|
||||
});
|
||||
const response =
|
||||
rule?.mutateResponse !== undefined
|
||||
? await rule.mutateResponse({
|
||||
request,
|
||||
response: forwarded,
|
||||
})
|
||||
: forwarded;
|
||||
await params.onExchange?.({
|
||||
...(context !== undefined ? { context } : {}),
|
||||
request,
|
||||
response,
|
||||
});
|
||||
writeForwardedResponse(res, response);
|
||||
} catch (error) {
|
||||
const failure =
|
||||
error instanceof MatrixQaFaultProxyHttpError
|
||||
? {
|
||||
body: {
|
||||
errcode: error.code,
|
||||
error: error.message,
|
||||
},
|
||||
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
|
||||
status: error.status,
|
||||
}
|
||||
: {
|
||||
body: {
|
||||
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
status: 502,
|
||||
};
|
||||
const response = normalizeJsonResponse(failure);
|
||||
if (observedRequest) {
|
||||
await params.onExchange?.({
|
||||
...(observedContext !== undefined ? { context: observedContext } : {}),
|
||||
request: observedRequest,
|
||||
response,
|
||||
});
|
||||
}
|
||||
writeForwardedResponse(res, response, {
|
||||
preserveConnectionClose:
|
||||
error instanceof MatrixQaFaultProxyHttpError && error.status === 413,
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
292
extensions/qa-matrix/src/substrate/harness.runtime.test.ts
Normal file
292
extensions/qa-matrix/src/substrate/harness.runtime.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// Qa Matrix tests cover harness plugin behavior.
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { testing, startMatrixQaHarness, writeMatrixQaHarnessFiles } from "./harness.runtime.js";
|
||||
import type { MatrixQaRecordingProxy } from "./recording-proxy.js";
|
||||
|
||||
type MatrixQaHarnessDeps = Parameters<typeof startMatrixQaHarness>[1];
|
||||
type MatrixQaHarnessResult = Awaited<ReturnType<typeof startMatrixQaHarness>>;
|
||||
|
||||
async function withStartedMatrixHarness(
|
||||
deps: MatrixQaHarnessDeps,
|
||||
verify: (params: { outputDir: string; result: MatrixQaHarnessResult }) => Promise<void> | void,
|
||||
) {
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
|
||||
|
||||
try {
|
||||
const startRecordingProxyImpl =
|
||||
deps?.startRecordingProxyImpl ??
|
||||
(async ({ targetBaseUrl }: { targetBaseUrl: string }) =>
|
||||
({
|
||||
baseUrl: targetBaseUrl,
|
||||
buildManifest: vi.fn(),
|
||||
records: () => [],
|
||||
setScenarioId: vi.fn(),
|
||||
stop: vi.fn(async () => {}),
|
||||
}) as unknown as MatrixQaRecordingProxy);
|
||||
const result = await startMatrixQaHarness(
|
||||
{
|
||||
outputDir,
|
||||
repoRoot: "/repo/openclaw",
|
||||
homeserverPort: 28008,
|
||||
},
|
||||
{ ...deps, startRecordingProxyImpl },
|
||||
);
|
||||
await verify({ outputDir, result });
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function createContainerNetworkRunCommand(calls?: string[]) {
|
||||
return async function runCommand(command: string, args: string[], cwd?: string) {
|
||||
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: "" };
|
||||
};
|
||||
}
|
||||
|
||||
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (predicate(item)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
|
||||
await withStartedMatrixHarness(
|
||||
{
|
||||
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),
|
||||
},
|
||||
async ({ outputDir, result }) => {
|
||||
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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("stops Tuwunel when recorder startup fails", async () => {
|
||||
const calls: string[] = [];
|
||||
await withTempDir("matrix-qa-harness-", async (outputDir) => {
|
||||
await expect(
|
||||
startMatrixQaHarness(
|
||||
{ outputDir, repoRoot: "/repo/openclaw" },
|
||||
{
|
||||
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 () => ({ ok: true })),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
resolveHostPortImpl: vi.fn(async (port: number) => port),
|
||||
startRecordingProxyImpl: vi.fn(async () => {
|
||||
throw new Error("recorder startup failed");
|
||||
}),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("recorder startup failed");
|
||||
expect(calls.filter((call) => call.includes("down --remove-orphans"))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats empty Docker health fields as a fallback to running state", async () => {
|
||||
await withStartedMatrixHarness(
|
||||
{
|
||||
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),
|
||||
},
|
||||
({ result }) => {
|
||||
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels Matrix versions probe response bodies", async () => {
|
||||
const cancel = vi.fn(async () => {});
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true, body: { cancel } }));
|
||||
|
||||
await expect(
|
||||
testing.isMatrixVersionsReachable("http://127.0.0.1:28008/", fetchImpl),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:28008/_matrix/client/versions");
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the container IP when the host port is unreachable", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await withStartedMatrixHarness(
|
||||
{
|
||||
runCommand: createContainerNetworkRunCommand(calls),
|
||||
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),
|
||||
},
|
||||
({ outputDir, result }) => {
|
||||
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}}{{println .IPAddress}}{{end}} container-123 @/repo/openclaw",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the host URL when the container IP is also unreachable", async () => {
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
await withStartedMatrixHarness(
|
||||
{
|
||||
runCommand: createContainerNetworkRunCommand(),
|
||||
fetchImpl: vi.fn(async (input: string) => {
|
||||
fetchCalls.push(input);
|
||||
return {
|
||||
ok:
|
||||
input === "http://127.0.0.1:28008/_matrix/client/versions" &&
|
||||
countMatching(fetchCalls, (url) => url === input) > 1,
|
||||
};
|
||||
}),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
resolveHostPortImpl: vi.fn(async (port: number) => port),
|
||||
},
|
||||
({ result }) => {
|
||||
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",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps probing the container URL until it becomes reachable", async () => {
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
await withStartedMatrixHarness(
|
||||
{
|
||||
runCommand: createContainerNetworkRunCommand(),
|
||||
fetchImpl: vi.fn(async (input: string) => {
|
||||
fetchCalls.push(input);
|
||||
return {
|
||||
ok:
|
||||
input === "http://172.18.0.10:8008/_matrix/client/versions" &&
|
||||
countMatching(fetchCalls, (url) => url === input) > 1,
|
||||
};
|
||||
}),
|
||||
sleepImpl: vi.fn(async () => {}),
|
||||
resolveHostPortImpl: vi.fn(async (port: number) => port),
|
||||
},
|
||||
({ result }) => {
|
||||
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",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
370
extensions/qa-matrix/src/substrate/harness.runtime.ts
Normal file
370
extensions/qa-matrix/src/substrate/harness.runtime.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Qa Matrix plugin module implements harness behavior.
|
||||
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";
|
||||
import { startMatrixQaRecordingProxy, type MatrixQaRecordingProxy } from "./recording-proxy.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";
|
||||
const MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
|
||||
|
||||
type MatrixQaHarnessManifest = {
|
||||
image: string;
|
||||
serverName: string;
|
||||
homeserverPort: number;
|
||||
composeFile: string;
|
||||
dataDir: string;
|
||||
};
|
||||
|
||||
type MatrixQaHarnessFiles = {
|
||||
outputDir: string;
|
||||
composeFile: string;
|
||||
manifestPath: string;
|
||||
image: string;
|
||||
serverName: string;
|
||||
homeserverPort: number;
|
||||
registrationToken: string;
|
||||
};
|
||||
|
||||
type MatrixQaHarness = MatrixQaHarnessFiles & {
|
||||
baseUrl: string;
|
||||
recording: MatrixQaRecordingProxy;
|
||||
restartService(): Promise<void>;
|
||||
stopCommand: string;
|
||||
stop(): Promise<void>;
|
||||
upstreamBaseUrl: string;
|
||||
};
|
||||
|
||||
function buildVersionsUrl(baseUrl: string) {
|
||||
return `${baseUrl}_matrix/client/versions`;
|
||||
}
|
||||
|
||||
async function isMatrixVersionsReachable(baseUrl: string, fetchImpl: FetchLike) {
|
||||
let response: Awaited<ReturnType<FetchLike>> | undefined;
|
||||
try {
|
||||
response = await fetchImpl(buildVersionsUrl(baseUrl));
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
await response?.body?.cancel?.();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function withMatrixQaHarnessTimeout<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
task: Promise<T>,
|
||||
): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
startRecordingProxyImpl?: typeof startMatrixQaRecordingProxy;
|
||||
},
|
||||
): 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 startRecordingProxyImpl = deps?.startRecordingProxyImpl ?? startMatrixQaRecordingProxy;
|
||||
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 upstreamBaseUrl = hostBaseUrl;
|
||||
const hostReachable = await isMatrixVersionsReachable(hostBaseUrl, fetchImpl);
|
||||
if (!hostReachable) {
|
||||
const containerBaseUrl = await resolveComposeServiceUrl(
|
||||
MATRIX_QA_SERVICE,
|
||||
MATRIX_QA_INTERNAL_PORT,
|
||||
files.composeFile,
|
||||
repoRoot,
|
||||
runCommand,
|
||||
);
|
||||
upstreamBaseUrl = await waitForReachableMatrixBaseUrl({
|
||||
composeFile: files.composeFile,
|
||||
containerBaseUrl,
|
||||
fetchImpl,
|
||||
hostBaseUrl,
|
||||
sleepImpl,
|
||||
});
|
||||
}
|
||||
|
||||
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
|
||||
label: "Matrix homeserver",
|
||||
composeFile: files.composeFile,
|
||||
fetchImpl,
|
||||
sleepImpl,
|
||||
});
|
||||
let recording: MatrixQaRecordingProxy;
|
||||
try {
|
||||
recording = await startRecordingProxyImpl({ targetBaseUrl: upstreamBaseUrl });
|
||||
} catch (error) {
|
||||
await withMatrixQaHarnessTimeout(
|
||||
"Matrix homeserver cleanup after recorder startup failure",
|
||||
MATRIX_QA_CLEANUP_TIMEOUT_MS,
|
||||
runCommand(
|
||||
"docker",
|
||||
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
|
||||
repoRoot,
|
||||
),
|
||||
).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const waitForReady = async () => {
|
||||
await sleepImpl(1_000);
|
||||
await waitForDockerServiceHealth(
|
||||
MATRIX_QA_SERVICE,
|
||||
files.composeFile,
|
||||
repoRoot,
|
||||
runCommand,
|
||||
sleepImpl,
|
||||
);
|
||||
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
|
||||
label: "Matrix homeserver",
|
||||
composeFile: files.composeFile,
|
||||
fetchImpl,
|
||||
sleepImpl,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...files,
|
||||
baseUrl: recording.baseUrl,
|
||||
recording,
|
||||
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() {
|
||||
const results = await Promise.allSettled([
|
||||
recording.stop(),
|
||||
withMatrixQaHarnessTimeout(
|
||||
"Matrix homeserver cleanup",
|
||||
MATRIX_QA_CLEANUP_TIMEOUT_MS,
|
||||
runCommand(
|
||||
"docker",
|
||||
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
|
||||
repoRoot,
|
||||
),
|
||||
),
|
||||
]);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Matrix QA harness cleanup failed");
|
||||
}
|
||||
},
|
||||
upstreamBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
MATRIX_QA_DEFAULT_IMAGE,
|
||||
MATRIX_QA_DEFAULT_PORT,
|
||||
MATRIX_QA_DEFAULT_SERVER_NAME,
|
||||
MATRIX_QA_SERVICE,
|
||||
MATRIX_QA_CLEANUP_TIMEOUT_MS,
|
||||
buildVersionsUrl,
|
||||
isMatrixVersionsReachable,
|
||||
renderMatrixQaCompose,
|
||||
resolveMatrixQaHarnessImage,
|
||||
waitForReachableMatrixBaseUrl,
|
||||
};
|
||||
export { testing as __testing };
|
||||
714
extensions/qa-matrix/src/substrate/recording-proxy.test.ts
Normal file
714
extensions/qa-matrix/src/substrate/recording-proxy.test.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
// Qa Matrix tests cover redacted protocol recording and manifest derivation.
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startMatrixQaFaultProxy } from "./fault-proxy.js";
|
||||
import { normalizeMatrixQaRoute, startMatrixQaRecordingProxy } from "./recording-proxy.js";
|
||||
|
||||
const closeCallbacks: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (closeCallbacks.length > 0) {
|
||||
await closeCallbacks.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
async function startRecordingTarget(options?: { alwaysFailState?: boolean }) {
|
||||
let syncCount = 0;
|
||||
let stateCount = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://matrix.test");
|
||||
if (url.pathname.endsWith("/sync")) {
|
||||
syncCount += 1;
|
||||
const since = url.searchParams.get("since");
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
encrypted: {
|
||||
SECRET_NESTED_STORAGE_KEY: { ciphertext: "secret-nested-ciphertext" },
|
||||
},
|
||||
unsigned: {
|
||||
"secret-custom-field-name": "secret-custom-field-value",
|
||||
},
|
||||
url: "mxc://matrix.test/secret-media",
|
||||
info: { mimetype: "image/png" },
|
||||
},
|
||||
type: "m.cross_signing.master",
|
||||
},
|
||||
],
|
||||
},
|
||||
device_keys: {
|
||||
"@secret-user:matrix.test": {
|
||||
SECRET_DEVICE: {
|
||||
keys: {
|
||||
"ed25519:SECRET_DEVICE": "secret-signing-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
device_one_time_keys_count: { signed_curve25519: 12 },
|
||||
device_unused_fallback_key_types: ["signed_curve25519"],
|
||||
next_batch: since === "echoed-unknown" ? since : `secret-sync-${syncCount}`,
|
||||
device_lists: { changed: ["secret-device"] },
|
||||
rooms: {
|
||||
join: {
|
||||
"!secret-room:matrix.test": {
|
||||
timeline: {
|
||||
events: [
|
||||
{ type: "m.room.message" },
|
||||
{ sender: "@secret-sender:matrix.test", type: "m.room.encrypted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.includes("/room_keys/keys")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
rooms: {
|
||||
"!secret-backup-room:matrix.test": {
|
||||
sessions: {
|
||||
"secret-backup-session": { first_message_index: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.endsWith("/keys/upload")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ one_time_key_counts: {} }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.endsWith("/keys/query")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
failures: {
|
||||
"secret-server.example": { errcode: "M_UNAVAILABLE" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.includes("/account_data/")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.includes("/sendToDevice/")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.includes("/state/")) {
|
||||
stateCount += 1;
|
||||
const status = options?.alwaysFailState || stateCount === 1 ? 401 : 200;
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(
|
||||
status === 401
|
||||
? JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" })
|
||||
: JSON.stringify({ name: "secret-state-name" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" }));
|
||||
});
|
||||
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("recording target did not bind");
|
||||
}
|
||||
closeCallbacks.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
describe("Matrix QA recording proxy", () => {
|
||||
it("records only redacted shapes and derives scenario expectations", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const proxy = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => proxy.stop());
|
||||
proxy.setScenarioId("matrix-recording-test");
|
||||
|
||||
const firstSync = await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&access_token=secret-query`,
|
||||
{ headers: { authorization: "Bearer secret-header" } },
|
||||
);
|
||||
const firstBody = (await firstSync.json()) as { next_batch: string };
|
||||
await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&since=${encodeURIComponent(firstBody.next_batch)}`,
|
||||
{ headers: { authorization: "Bearer secret-header" } },
|
||||
);
|
||||
const postState = async () => {
|
||||
await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/rooms/!secret:matrix.test/state/m.room.name`,
|
||||
{
|
||||
body: JSON.stringify({ password: "secret-password", body: "secret-message" }),
|
||||
headers: {
|
||||
authorization: "Bearer secret-header",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
};
|
||||
await postState();
|
||||
await fetch(`${proxy.baseUrl}/_matrix/client/v3/room_keys/keys`, {
|
||||
headers: {
|
||||
authorization: "Bearer secret-header",
|
||||
},
|
||||
});
|
||||
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/upload`, {
|
||||
body: JSON.stringify({
|
||||
device_keys: {
|
||||
algorithms: ["m.olm.v1.curve25519-aes-sha2"],
|
||||
device_id: "SECRET_UPLOAD_DEVICE",
|
||||
keys: { "ed25519:SECRET_UPLOAD_DEVICE": "secret-upload-key" },
|
||||
user_id: "@secret-upload:matrix.test",
|
||||
},
|
||||
one_time_keys: {
|
||||
"signed_curve25519:SECRET_ONE_TIME": { key: "secret-one-time-key" },
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
authorization: "Bearer secret-header",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/query`, {
|
||||
body: JSON.stringify({ device_keys: {} }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.cross_signing.master`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
encrypted: {
|
||||
SECRET_STORAGE_KEY: { ciphertext: "secret-ciphertext" },
|
||||
},
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
await fetch(
|
||||
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.megolm_backup.v1`,
|
||||
{
|
||||
body: JSON.stringify({ version: "1" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
await fetch(`${proxy.baseUrl}/_matrix/client/v3/sendToDevice/m.room.encrypted/transaction-42`, {
|
||||
body: JSON.stringify({
|
||||
messages: {
|
||||
"@secret-recipient:matrix.test": {
|
||||
SECRET_RECIPIENT_DEVICE: { content: "secret-device-message" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
await postState();
|
||||
|
||||
const records = proxy.records();
|
||||
const serialized = JSON.stringify(records);
|
||||
expect(serialized).not.toContain("secret-query");
|
||||
expect(serialized).not.toContain("secret-header");
|
||||
expect(serialized).not.toContain("secret-password");
|
||||
expect(serialized).not.toContain("secret-message");
|
||||
expect(serialized).not.toContain("secret-sync");
|
||||
expect(serialized).not.toContain("secret-response");
|
||||
expect(serialized).not.toContain("secret-room");
|
||||
expect(serialized).not.toContain("secret-backup-room");
|
||||
expect(serialized).not.toContain("secret-backup-session");
|
||||
expect(serialized).not.toContain("secret-state-name");
|
||||
expect(serialized).not.toContain("secret-user");
|
||||
expect(serialized).not.toContain("SECRET_DEVICE");
|
||||
expect(serialized).not.toContain("SECRET_UPLOAD_DEVICE");
|
||||
expect(serialized).not.toContain("SECRET_ONE_TIME");
|
||||
expect(serialized).not.toContain("SECRET_RECIPIENT_DEVICE");
|
||||
expect(serialized).not.toContain("secret-server.example");
|
||||
expect(serialized).not.toContain("SECRET_STORAGE_KEY");
|
||||
expect(serialized).not.toContain("SECRET_NESTED_STORAGE_KEY");
|
||||
expect(serialized).not.toContain("secret-custom-field-name");
|
||||
const keyUpload = records.find((record) => record.request.route.endsWith("/keys/upload"));
|
||||
expect(keyUpload?.request.body).toEqual({
|
||||
kind: "json",
|
||||
fields: [
|
||||
"device_keys.algorithms",
|
||||
"device_keys.device_id",
|
||||
"device_keys.keys.{keyId}",
|
||||
"device_keys.user_id",
|
||||
"one_time_keys.{keyId}.key",
|
||||
],
|
||||
});
|
||||
const sendToDevice = records.find((record) => record.request.route.includes("sendToDevice"));
|
||||
expect(sendToDevice?.request.body).toEqual({
|
||||
kind: "json",
|
||||
fields: ["messages.{userId}.{deviceId}.content"],
|
||||
});
|
||||
const keyQuery = records.find((record) => record.request.route.endsWith("/keys/query"));
|
||||
expect(keyQuery?.response.body).toEqual({
|
||||
kind: "json",
|
||||
fields: ["failures.{serverName}.errcode"],
|
||||
});
|
||||
const secretStorage = records.find((record) =>
|
||||
record.request.route.endsWith("/account_data/m.cross_signing.master"),
|
||||
);
|
||||
expect(secretStorage?.request.body).toEqual({
|
||||
kind: "json",
|
||||
fields: ["encrypted.{keyId}.ciphertext"],
|
||||
});
|
||||
expect(records[1]?.sync).toMatchObject({ continuity: true, since: "sync-1" });
|
||||
|
||||
const manifest = proxy.buildManifest({
|
||||
generatedAt: "2026-07-03T00:00:00.000Z",
|
||||
requestedProfile: "all",
|
||||
scenarioIds: ["matrix-recording-test"],
|
||||
substrate: { id: "tuwunel", version: "v1.5.1" },
|
||||
});
|
||||
const expectation = manifest.scenarios["matrix-recording-test"];
|
||||
expect(manifest.profile).toEqual({
|
||||
derivedFrom: "observed-request-response-traffic",
|
||||
id: "matrix-qa-v1",
|
||||
});
|
||||
expect(expectation?.syncTokens).toEqual({
|
||||
continuityObserved: true,
|
||||
incrementalRequests: 1,
|
||||
initialRequests: 1,
|
||||
responseTokens: 2,
|
||||
});
|
||||
expect(expectation?.state.device).toEqual([
|
||||
"/_matrix/client/v3/keys/upload",
|
||||
"/_matrix/client/v3/sync",
|
||||
]);
|
||||
expect(expectation?.state.key).toContain(
|
||||
"/_matrix/client/v3/user/{userId}/account_data/m.cross_signing.master",
|
||||
);
|
||||
expect(expectation?.state.key).toContain("/_matrix/client/v3/sync");
|
||||
expect(expectation?.state.backup).toContain(
|
||||
"/_matrix/client/v3/user/{userId}/account_data/m.megolm_backup.v1",
|
||||
);
|
||||
expect(expectation?.state.media).toContain("/_matrix/client/v3/sync");
|
||||
expect(expectation?.ordering[0]).toMatchObject({
|
||||
requestBody: { kind: "empty" },
|
||||
responseBody: {
|
||||
kind: "json",
|
||||
fields: expect.arrayContaining([
|
||||
"rooms.join.{roomId}.timeline.events[].sender",
|
||||
"rooms.join.{roomId}.timeline.events[].type",
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(expectation?.retries).toEqual([]);
|
||||
expect(expectation?.errors).toEqual([
|
||||
{
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
method: "POST",
|
||||
route: "/_matrix/client/v3/rooms/{roomId}/state/m.room.name",
|
||||
status: 401,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes substrate-specific Matrix identifiers from routes", () => {
|
||||
expect(
|
||||
normalizeMatrixQaRoute(
|
||||
"/_matrix/client/v3/rooms/!room%3Amatrix.test/send/m.room.message/txn-123",
|
||||
),
|
||||
).toBe("/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{transactionId}");
|
||||
expect(
|
||||
normalizeMatrixQaRoute(
|
||||
"/_matrix/client/v3/rooms/!room%3Amatrix.test/redact/$event%3Amatrix.test/txn-123",
|
||||
),
|
||||
).toBe("/_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{transactionId}");
|
||||
expect(normalizeMatrixQaRoute("/_matrix/media/v3/download/matrix.test/secret-media-id")).toBe(
|
||||
"/_matrix/media/v3/download/{serverName}/{mediaId}",
|
||||
);
|
||||
expect(
|
||||
normalizeMatrixQaRoute(
|
||||
"/_matrix/media/v3/download/matrix.test/secret-media-id/private-report.pdf",
|
||||
),
|
||||
).toBe("/_matrix/media/v3/download/{serverName}/{mediaId}/{filename}");
|
||||
expect(
|
||||
normalizeMatrixQaRoute("/_matrix/client/v1/media/thumbnail/matrix.test/secret-media-id"),
|
||||
).toBe("/_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}");
|
||||
expect(
|
||||
normalizeMatrixQaRoute("/_matrix/client/v3/user/@alice%3Amatrix.test/filter/filter-42"),
|
||||
).toBe("/_matrix/client/v3/user/{userId}/filter/{filterId}");
|
||||
expect(
|
||||
normalizeMatrixQaRoute(
|
||||
"/_matrix/client/v3/user/@alice%3Amatrix.test/account_data/m.secret_storage.key.secret-key-id",
|
||||
),
|
||||
).toBe("/_matrix/client/v3/user/{userId}/account_data/m.secret_storage.key.{keyId}");
|
||||
expect(
|
||||
normalizeMatrixQaRoute("/_matrix/client/v3/room_keys/keys/!room%3Amatrix.test/session-42"),
|
||||
).toBe("/_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}");
|
||||
});
|
||||
|
||||
it("records the response observed after scenario-local fault injection", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-fault-recording-test");
|
||||
const faultProxy = await startMatrixQaFaultProxy({
|
||||
targetBaseUrl,
|
||||
...recording,
|
||||
rules: [
|
||||
{
|
||||
id: "backup-unavailable",
|
||||
match: (request) => request.path.endsWith("/room_keys/version"),
|
||||
response: () => ({
|
||||
body: { errcode: "M_NOT_FOUND", error: "secret-fault-message" },
|
||||
status: 404,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
closeCallbacks.push(() => faultProxy.stop());
|
||||
|
||||
await fetch(`${faultProxy.baseUrl}/_matrix/client/v3/room_keys/version`);
|
||||
|
||||
const record = recording
|
||||
.records()
|
||||
.find((entry) => entry.scenarioId === "matrix-fault-recording-test");
|
||||
expect(record?.response).toMatchObject({ errcode: "M_NOT_FOUND", status: 404 });
|
||||
expect(JSON.stringify(record)).not.toContain("secret-fault-message");
|
||||
});
|
||||
|
||||
it("records proxy-generated upstream failures", async () => {
|
||||
const recording = await startMatrixQaRecordingProxy({
|
||||
targetBaseUrl: "http://127.0.0.1:1",
|
||||
});
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-upstream-failure-test");
|
||||
|
||||
const response = await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0`);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(recording.records().at(-1)?.response).toMatchObject({
|
||||
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts signature upload device and cross-signing key identifiers", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-signatures-recording-test");
|
||||
|
||||
await fetch(`${recording.baseUrl}/_matrix/client/v3/keys/signatures/upload`, {
|
||||
body: JSON.stringify({
|
||||
"@secret-signing-user:matrix.test": {
|
||||
SECRET_CROSS_SIGNING_KEY: { signatures: {} },
|
||||
},
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const record = recording.records().at(-1);
|
||||
expect(record?.request.body).toEqual({
|
||||
kind: "json",
|
||||
fields: ["{userId}.{deviceOrKeyId}.signatures"],
|
||||
});
|
||||
expect(JSON.stringify(record)).not.toContain("secret-signing-user");
|
||||
expect(JSON.stringify(record)).not.toContain("SECRET_CROSS_SIGNING_KEY");
|
||||
});
|
||||
|
||||
it("reports sync continuity only when every incremental token is recognized", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-sync-discontinuity-test");
|
||||
|
||||
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=unknown-token`);
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-sync-discontinuity-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-sync-discontinuity-test"]?.syncTokens).toMatchObject({
|
||||
continuityObserved: false,
|
||||
incrementalRequests: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not learn an unknown request token from the same sync response", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-echoed-sync-token-test");
|
||||
|
||||
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=echoed-unknown`);
|
||||
|
||||
expect(recording.records().at(-1)?.sync).toMatchObject({
|
||||
continuity: false,
|
||||
since: "sync-unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not conflate distinct same-shape operations as retries", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-distinct-operation-test");
|
||||
|
||||
await fetch(
|
||||
`${recording.baseUrl}/_matrix/client/v3/rooms/!first:matrix.test/state/m.room.name`,
|
||||
{
|
||||
body: JSON.stringify({ name: "first" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await fetch(
|
||||
`${recording.baseUrl}/_matrix/client/v3/rooms/!second:matrix.test/state/m.room.name`,
|
||||
{
|
||||
body: JSON.stringify({ name: "second" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-distinct-operation-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-distinct-operation-test"]?.retries).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not conflate identical operations from different principals", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-principal-retry-test");
|
||||
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
|
||||
|
||||
await fetch(endpoint, {
|
||||
body: JSON.stringify({ name: "same" }),
|
||||
headers: { authorization: "Bearer first", "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
await fetch(endpoint, {
|
||||
body: JSON.stringify({ name: "same" }),
|
||||
headers: { authorization: "Bearer second", "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-principal-retry-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-principal-retry-test"]?.retries).toEqual([]);
|
||||
});
|
||||
|
||||
it("attributes sync completion to the active scenario", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("previous-scenario");
|
||||
const context = recording.createExchangeContext?.({
|
||||
body: Buffer.alloc(0),
|
||||
headers: {},
|
||||
method: "GET",
|
||||
path: "/_matrix/client/v3/sync",
|
||||
search: "?timeout=0",
|
||||
});
|
||||
recording.setScenarioId("active-scenario");
|
||||
await recording.onExchange?.({
|
||||
context,
|
||||
request: {
|
||||
body: Buffer.alloc(0),
|
||||
headers: {},
|
||||
method: "GET",
|
||||
path: "/_matrix/client/v3/sync",
|
||||
search: "?timeout=0",
|
||||
},
|
||||
response: {
|
||||
body: Buffer.from(JSON.stringify({ next_batch: "sync-complete" })),
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
expect(recording.records().at(-1)?.scenarioId).toBe("active-scenario");
|
||||
});
|
||||
|
||||
it("does not share sync-token continuity across principals", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-principal-sync-test");
|
||||
const exchange = async (bearerToken: string, search: string, nextBatch: string) => {
|
||||
const request = {
|
||||
bearerToken,
|
||||
body: Buffer.alloc(0),
|
||||
headers: {},
|
||||
method: "GET",
|
||||
path: "/_matrix/client/v3/sync",
|
||||
search,
|
||||
};
|
||||
await recording.onExchange?.({
|
||||
context: recording.createExchangeContext?.(request),
|
||||
request,
|
||||
response: {
|
||||
body: Buffer.from(JSON.stringify({ next_batch: nextBatch })),
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await exchange("first", "?timeout=0", "shared-token");
|
||||
await exchange("second", "?timeout=0&since=shared-token", "second-token");
|
||||
|
||||
expect(recording.records().at(-1)?.sync).toMatchObject({
|
||||
continuity: false,
|
||||
since: "sync-unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("ends a retry chain at the first successful recovery", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-retry-boundary-test");
|
||||
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
|
||||
const request = () =>
|
||||
fetch(endpoint, {
|
||||
body: JSON.stringify({ name: "same" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await request();
|
||||
await request();
|
||||
await request();
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-retry-boundary-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-retry-boundary-test"]?.retries).toEqual([
|
||||
expect.objectContaining({ attempts: 2, statuses: [401, 200] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("records exhausted retry chains without recovery", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget({ alwaysFailState: true });
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-exhausted-retry-test");
|
||||
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!exhausted:matrix.test/state/m.room.name`;
|
||||
|
||||
await fetch(endpoint, { method: "POST" });
|
||||
await fetch(endpoint, { method: "POST" });
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-exhausted-retry-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-exhausted-retry-test"]?.retries).toEqual([
|
||||
expect.objectContaining({ attempts: 2, statuses: [401, 401] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not infer retries across intervening operations", async () => {
|
||||
const targetBaseUrl = await startRecordingTarget();
|
||||
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-independent-operation-test");
|
||||
const stateEndpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
|
||||
|
||||
await fetch(stateEndpoint, { method: "POST" });
|
||||
await fetch(`${recording.baseUrl}/_matrix/client/versions`);
|
||||
await fetch(stateEndpoint, { method: "POST" });
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-independent-operation-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-independent-operation-test"]?.retries).toEqual([]);
|
||||
});
|
||||
|
||||
it("records repeated retry chains for the same operation", async () => {
|
||||
let requestCount = 0;
|
||||
const server = createServer((_req, res) => {
|
||||
requestCount += 1;
|
||||
const status = requestCount % 2 === 1 ? 503 : 200;
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(status === 200 ? {} : { errcode: "M_UNAVAILABLE" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
closeCallbacks.push(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("retry target did not bind");
|
||||
}
|
||||
const recording = await startMatrixQaRecordingProxy({
|
||||
targetBaseUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
closeCallbacks.push(() => recording.stop());
|
||||
recording.setScenarioId("matrix-repeated-retry-test");
|
||||
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await fetch(endpoint, { method: "POST" });
|
||||
}
|
||||
|
||||
const manifest = recording.buildManifest({
|
||||
requestedProfile: "test",
|
||||
scenarioIds: ["matrix-repeated-retry-test"],
|
||||
substrate: { id: "tuwunel", version: "test" },
|
||||
});
|
||||
expect(manifest.scenarios["matrix-repeated-retry-test"]?.retries).toEqual([
|
||||
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
|
||||
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
732
extensions/qa-matrix/src/substrate/recording-proxy.ts
Normal file
732
extensions/qa-matrix/src/substrate/recording-proxy.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
// Qa Matrix plugin module records redacted Matrix protocol behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import {
|
||||
startMatrixQaFaultProxy,
|
||||
type MatrixQaFaultProxyExchange,
|
||||
type MatrixQaFaultProxyObserver,
|
||||
} from "./fault-proxy.js";
|
||||
|
||||
const MATRIX_QA_RECORDING_PROFILE = "matrix-qa-v1";
|
||||
const REDACTED_QUERY_VALUE = "[redacted]";
|
||||
|
||||
type MatrixQaStateFamily = "backup" | "device" | "key" | "media" | "sync-token";
|
||||
|
||||
type MatrixQaBodyShape =
|
||||
| { kind: "binary" }
|
||||
| { kind: "empty" }
|
||||
| { kind: "json"; fields: string[] }
|
||||
| { kind: "text" };
|
||||
|
||||
export type MatrixQaRecordedExchange = {
|
||||
categories: MatrixQaStateFamily[];
|
||||
request: {
|
||||
body: MatrixQaBodyShape;
|
||||
method: string;
|
||||
query: Record<string, string>;
|
||||
route: string;
|
||||
};
|
||||
response: {
|
||||
body: MatrixQaBodyShape;
|
||||
errcode?: string;
|
||||
status: number;
|
||||
};
|
||||
scenarioId: string;
|
||||
sequence: number;
|
||||
sync?: {
|
||||
continuity?: boolean;
|
||||
nextBatch?: string;
|
||||
since?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MatrixQaInternalRecordedExchange = MatrixQaRecordedExchange & {
|
||||
operationFingerprint: string;
|
||||
};
|
||||
|
||||
type MatrixQaRouteExpectation = {
|
||||
count: number;
|
||||
method: string;
|
||||
route: string;
|
||||
statuses: number[];
|
||||
};
|
||||
|
||||
type MatrixQaOrderingExpectation = {
|
||||
categories: MatrixQaStateFamily[];
|
||||
count: number;
|
||||
method: string;
|
||||
requestBody: MatrixQaBodyShape;
|
||||
responseBody: MatrixQaBodyShape;
|
||||
route: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type MatrixQaScenarioRouteStateExpectation = {
|
||||
errors: Array<{
|
||||
errcode?: string;
|
||||
method: string;
|
||||
route: string;
|
||||
status: number;
|
||||
}>;
|
||||
ordering: MatrixQaOrderingExpectation[];
|
||||
retries: Array<{
|
||||
attempts: number;
|
||||
kind: "retry";
|
||||
method: string;
|
||||
route: string;
|
||||
statuses: number[];
|
||||
}>;
|
||||
routes: MatrixQaRouteExpectation[];
|
||||
state: Record<MatrixQaStateFamily, string[]>;
|
||||
syncTokens: {
|
||||
continuityObserved: boolean;
|
||||
incrementalRequests: number;
|
||||
initialRequests: number;
|
||||
responseTokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixQaRouteStateManifest = {
|
||||
generatedAt: string;
|
||||
phases: Record<string, MatrixQaScenarioRouteStateExpectation>;
|
||||
profile: {
|
||||
derivedFrom: "observed-request-response-traffic";
|
||||
id: typeof MATRIX_QA_RECORDING_PROFILE;
|
||||
};
|
||||
requestedProfile: string;
|
||||
scenarios: Record<string, MatrixQaScenarioRouteStateExpectation>;
|
||||
substrate: {
|
||||
id: string;
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixQaRecordingProxy = MatrixQaFaultProxyObserver & {
|
||||
baseUrl: string;
|
||||
buildManifest(params: {
|
||||
generatedAt?: string;
|
||||
requestedProfile: string;
|
||||
scenarioIds: string[];
|
||||
substrate: MatrixQaRouteStateManifest["substrate"];
|
||||
}): MatrixQaRouteStateManifest;
|
||||
records(): MatrixQaRecordedExchange[];
|
||||
setScenarioId(scenarioId: string): void;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeHeaderValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value.join(", ") : value;
|
||||
}
|
||||
|
||||
function hasJsonContentType(headers: Headers | IncomingHttpHeaders) {
|
||||
const raw = headers instanceof Headers ? headers.get("content-type") : headers["content-type"];
|
||||
return (
|
||||
normalizeHeaderValue(raw ?? undefined)
|
||||
?.toLowerCase()
|
||||
.includes("json") === true
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeJsonMapKey(key: string, prefix: string, route: string) {
|
||||
if (route.endsWith("/sync") && prefix === "rooms") {
|
||||
return key;
|
||||
}
|
||||
if (key.startsWith("!")) {
|
||||
return "{roomId}";
|
||||
}
|
||||
if (key.startsWith("@")) {
|
||||
return "{userId}";
|
||||
}
|
||||
if (key.startsWith("$")) {
|
||||
return "{eventId}";
|
||||
}
|
||||
if (route.endsWith("/keys/signatures/upload") && prefix === "") {
|
||||
return "{userId}";
|
||||
}
|
||||
if (route.endsWith("/keys/signatures/upload") && prefix === "{userId}") {
|
||||
return "{deviceOrKeyId}";
|
||||
}
|
||||
if (route.endsWith("/keys/upload") && prefix === "one_time_keys") {
|
||||
return "{keyId}";
|
||||
}
|
||||
if (/(?:^|\.)encrypted$/u.test(prefix)) {
|
||||
return "{keyId}";
|
||||
}
|
||||
if (prefix === "failures" && /\/keys\/(?:claim|query)$/u.test(route)) {
|
||||
return "{serverName}";
|
||||
}
|
||||
if (
|
||||
!route.endsWith("/keys/upload") &&
|
||||
/^(?:device_keys|master_keys|self_signing_keys|user_signing_keys)$/u.test(prefix)
|
||||
) {
|
||||
return "{userId}";
|
||||
}
|
||||
if (/^(?:device_keys|devices|messages|one_time_keys)\.\{userId\}$/u.test(prefix)) {
|
||||
return "{deviceId}";
|
||||
}
|
||||
if (!route.endsWith("/keys/upload") && prefix === "one_time_keys") {
|
||||
return "{userId}";
|
||||
}
|
||||
if (
|
||||
/^(?:device_keys\.)?\{userId\}\.\{deviceId\}\.(?:fallback_keys|keys|one_time_keys)$/u.test(
|
||||
prefix,
|
||||
) ||
|
||||
/^one_time_keys\.\{userId\}\.\{deviceId\}$/u.test(prefix) ||
|
||||
/^(?:ed25519|curve25519|signed_curve25519):/u.test(key)
|
||||
) {
|
||||
return "{keyId}";
|
||||
}
|
||||
if (/(?:^|\.)rooms$/u.test(prefix)) {
|
||||
return "{roomId}";
|
||||
}
|
||||
if (/(?:^|\.)rooms\.\{roomId\}\.sessions$/u.test(prefix)) {
|
||||
return "{sessionId}";
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function collectJsonFields(value: unknown, route: string, prefix = "", depth = 0): string[] {
|
||||
if (depth >= 8 || value === null || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return prefix ? [`${prefix}[]`] : [];
|
||||
}
|
||||
const arrayPrefix = prefix ? `${prefix}[]` : "[]";
|
||||
return [
|
||||
...new Set(value.flatMap((entry) => collectJsonFields(entry, route, arrayPrefix, depth + 1))),
|
||||
].toSorted();
|
||||
}
|
||||
const fixedDeviceKeysObject =
|
||||
prefix === "device_keys" &&
|
||||
["algorithms", "device_id", "keys", "signatures", "user_id"].some((key) => key in value);
|
||||
return Object.entries(value)
|
||||
.flatMap(([key, child]) => {
|
||||
const safeKey = fixedDeviceKeysObject ? key : normalizeJsonMapKey(key, prefix, route);
|
||||
const field = prefix ? `${prefix}.${safeKey}` : safeKey;
|
||||
if (
|
||||
/^(?:access_token|auth|body|ciphertext|content|file|formatted_body|password|recovery_key|session_data|token|unsigned)$/iu.test(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
return [field];
|
||||
}
|
||||
const nested = collectJsonFields(child, route, field, depth + 1);
|
||||
return nested.length > 0 ? nested : [field];
|
||||
})
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function parseJsonBody(body: Buffer, headers: Headers | IncomingHttpHeaders): unknown {
|
||||
if (body.byteLength === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const text = body.toString("utf8");
|
||||
const firstNonWhitespace = text.trimStart()[0];
|
||||
if (!hasJsonContentType(headers) && firstNonWhitespace !== "[" && firstNonWhitespace !== "{") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const MATRIX_QA_STATE_FIELD_MARKERS = new Set([
|
||||
"backup",
|
||||
"content_uri",
|
||||
"device_id",
|
||||
"device_keys",
|
||||
"device_lists",
|
||||
"device_one_time_keys_count",
|
||||
"device_unused_fallback_key_types",
|
||||
"mimetype",
|
||||
"one_time_key",
|
||||
"session_data",
|
||||
]);
|
||||
|
||||
function collectStateFieldMarkers(value: unknown, depth = 0): string[] {
|
||||
if (depth >= 8 || value === null || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value.flatMap((entry) => collectStateFieldMarkers(entry, depth + 1)))];
|
||||
}
|
||||
const markers = new Set<string>();
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (MATRIX_QA_STATE_FIELD_MARKERS.has(normalizedKey)) {
|
||||
markers.add(normalizedKey);
|
||||
}
|
||||
for (const marker of collectStateFieldMarkers(child, depth + 1)) {
|
||||
markers.add(marker);
|
||||
}
|
||||
}
|
||||
return [...markers];
|
||||
}
|
||||
|
||||
function extractStateFieldMarkers(body: Buffer, headers: Headers | IncomingHttpHeaders) {
|
||||
const parsed = parseJsonBody(body, headers);
|
||||
return parsed === undefined ? [] : collectStateFieldMarkers(parsed);
|
||||
}
|
||||
|
||||
function buildBodyShape(
|
||||
body: Buffer,
|
||||
headers: Headers | IncomingHttpHeaders,
|
||||
route: string,
|
||||
): MatrixQaBodyShape {
|
||||
if (body.byteLength === 0) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
const parsed = parseJsonBody(body, headers);
|
||||
if (parsed !== undefined) {
|
||||
return { kind: "json", fields: collectJsonFields(parsed, route) };
|
||||
}
|
||||
const contentType =
|
||||
headers instanceof Headers
|
||||
? headers.get("content-type")
|
||||
: normalizeHeaderValue(headers["content-type"]);
|
||||
return contentType?.toLowerCase().startsWith("text/") ? { kind: "text" } : { kind: "binary" };
|
||||
}
|
||||
|
||||
function normalizeMatrixIdSegment(segment: string) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
if (decoded.startsWith("!")) {
|
||||
return "{roomId}";
|
||||
}
|
||||
if (decoded.startsWith("@")) {
|
||||
return "{userId}";
|
||||
}
|
||||
if (decoded.startsWith("$")) {
|
||||
return "{eventId}";
|
||||
}
|
||||
if (decoded.startsWith("#")) {
|
||||
return "{roomAlias}";
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
export function normalizeMatrixQaRoute(pathname: string) {
|
||||
const segments = pathname.split("/");
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const previous = segments[index - 1];
|
||||
const beforePrevious = segments[index - 2];
|
||||
if (previous === "rooms") {
|
||||
segments[index] = "{roomId}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "profile" || previous === "user") {
|
||||
segments[index] = "{userId}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "filter") {
|
||||
segments[index] = "{filterId}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "join") {
|
||||
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
|
||||
continue;
|
||||
}
|
||||
if (previous === "devices") {
|
||||
segments[index] = "{deviceId}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "redact") {
|
||||
segments[index] = "{eventId}";
|
||||
continue;
|
||||
}
|
||||
if (beforePrevious === "send" || beforePrevious === "redact") {
|
||||
segments[index] = "{transactionId}";
|
||||
continue;
|
||||
}
|
||||
if (beforePrevious === "sendToDevice") {
|
||||
segments[index] = "{transactionId}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "version" && beforePrevious === "room_keys") {
|
||||
segments[index] = "{backupVersion}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "keys" && beforePrevious === "room_keys") {
|
||||
segments[index] = "{roomId}";
|
||||
continue;
|
||||
}
|
||||
if (segments[index - 2] === "keys" && segments[index - 3] === "room_keys") {
|
||||
segments[index] = "{sessionId}";
|
||||
continue;
|
||||
}
|
||||
if (segments[index - 2] === "state" && segments[index] !== "") {
|
||||
segments[index] = "{stateKey}";
|
||||
continue;
|
||||
}
|
||||
if (previous === "account_data" && segments[index]?.startsWith("m.secret_storage.key.")) {
|
||||
segments[index] = "m.secret_storage.key.{keyId}";
|
||||
continue;
|
||||
}
|
||||
const mediaActionIndex = segments.findIndex(
|
||||
(segment) => segment === "download" || segment === "thumbnail",
|
||||
);
|
||||
if (mediaActionIndex >= 0 && index === mediaActionIndex + 2) {
|
||||
segments[index] = "{mediaId}";
|
||||
segments[index - 1] = "{serverName}";
|
||||
continue;
|
||||
}
|
||||
if (mediaActionIndex >= 0 && index === mediaActionIndex + 3) {
|
||||
segments[index] = "{filename}";
|
||||
continue;
|
||||
}
|
||||
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function buildRedactedQuery(search: string, syncTokens: Map<string, string>) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of new URLSearchParams(search)) {
|
||||
if (key === "since") {
|
||||
result[key] = syncTokens.get(value) ?? "sync-unknown";
|
||||
} else if (key === "timeout" || key === "full_state" || key === "set_presence") {
|
||||
result[key] = value;
|
||||
} else {
|
||||
result[key] = REDACTED_QUERY_VALUE;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveStateFamilies(params: {
|
||||
requestFields: string[];
|
||||
responseFields: string[];
|
||||
route: string;
|
||||
}) {
|
||||
const fields = [...params.requestFields, ...params.responseFields].join(" ").toLowerCase();
|
||||
const route = params.route.toLowerCase();
|
||||
const families = new Set<MatrixQaStateFamily>();
|
||||
if (route.includes("/sync")) {
|
||||
families.add("sync-token");
|
||||
}
|
||||
if (route.includes("/room_keys/") || fields.includes("backup")) {
|
||||
families.add("backup");
|
||||
}
|
||||
if (route.includes("/account_data/m.megolm_backup.")) {
|
||||
families.add("backup");
|
||||
}
|
||||
if (
|
||||
route.includes("/keys/") ||
|
||||
route.includes("/sendtodevice/") ||
|
||||
route.includes("/account_data/m.cross_signing.") ||
|
||||
route.includes("/account_data/m.secret_storage.") ||
|
||||
fields.includes("one_time_key") ||
|
||||
fields.includes("device_one_time_keys_count") ||
|
||||
fields.includes("device_unused_fallback_key_types") ||
|
||||
fields.includes("device_keys") ||
|
||||
fields.includes("session_data")
|
||||
) {
|
||||
families.add("key");
|
||||
}
|
||||
if (
|
||||
route.includes("/devices") ||
|
||||
fields.includes("device_id") ||
|
||||
fields.includes("device_lists")
|
||||
) {
|
||||
families.add("device");
|
||||
}
|
||||
if (route.includes("/media/") || fields.includes("content_uri") || fields.includes("mimetype")) {
|
||||
families.add("media");
|
||||
}
|
||||
return [...families].toSorted();
|
||||
}
|
||||
|
||||
function buildExpectation(
|
||||
records: MatrixQaInternalRecordedExchange[],
|
||||
): MatrixQaScenarioRouteStateExpectation {
|
||||
const orderedRecords = records.toSorted((left, right) => left.sequence - right.sequence);
|
||||
const routeGroups = new Map<string, MatrixQaRouteExpectation>();
|
||||
const ordering: MatrixQaOrderingExpectation[] = [];
|
||||
const state = {
|
||||
backup: new Set<string>(),
|
||||
device: new Set<string>(),
|
||||
key: new Set<string>(),
|
||||
media: new Set<string>(),
|
||||
"sync-token": new Set<string>(),
|
||||
} satisfies Record<MatrixQaStateFamily, Set<string>>;
|
||||
|
||||
for (const record of orderedRecords) {
|
||||
const routeKey = `${record.request.method} ${record.request.route}`;
|
||||
const route = routeGroups.get(routeKey) ?? {
|
||||
count: 0,
|
||||
method: record.request.method,
|
||||
route: record.request.route,
|
||||
statuses: [],
|
||||
};
|
||||
route.count += 1;
|
||||
if (!route.statuses.includes(record.response.status)) {
|
||||
route.statuses.push(record.response.status);
|
||||
route.statuses.sort((left, right) => left - right);
|
||||
}
|
||||
routeGroups.set(routeKey, route);
|
||||
for (const category of record.categories) {
|
||||
state[category].add(record.request.route);
|
||||
}
|
||||
const previous = ordering.at(-1);
|
||||
if (
|
||||
previous?.method === record.request.method &&
|
||||
previous.route === record.request.route &&
|
||||
previous.status === record.response.status &&
|
||||
JSON.stringify(previous.requestBody) === JSON.stringify(record.request.body) &&
|
||||
JSON.stringify(previous.responseBody) === JSON.stringify(record.response.body) &&
|
||||
previous.categories.join("\0") === record.categories.join("\0")
|
||||
) {
|
||||
previous.count += 1;
|
||||
} else {
|
||||
ordering.push({
|
||||
categories: record.categories,
|
||||
count: 1,
|
||||
method: record.request.method,
|
||||
requestBody: record.request.body,
|
||||
responseBody: record.response.body,
|
||||
route: record.request.route,
|
||||
status: record.response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [...routeGroups.values()].toSorted((left, right) =>
|
||||
`${left.method} ${left.route}`.localeCompare(`${right.method} ${right.route}`),
|
||||
);
|
||||
const retries: MatrixQaScenarioRouteStateExpectation["retries"] = [];
|
||||
const adjacentOperationRuns: MatrixQaInternalRecordedExchange[][] = [];
|
||||
for (const record of orderedRecords) {
|
||||
const currentRun = adjacentOperationRuns.at(-1);
|
||||
if (currentRun?.[0]?.operationFingerprint === record.operationFingerprint) {
|
||||
currentRun.push(record);
|
||||
} else {
|
||||
adjacentOperationRuns.push([record]);
|
||||
}
|
||||
}
|
||||
for (const attempts of adjacentOperationRuns) {
|
||||
if (attempts[0]?.request.route.endsWith("/sync")) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const first = attempts[index];
|
||||
if (!first || first.response.status < 400) {
|
||||
continue;
|
||||
}
|
||||
const recoveryOffset = attempts
|
||||
.slice(index + 1)
|
||||
.findIndex((attempt) => attempt.response.status < 400);
|
||||
const retryEndIndex = recoveryOffset < 0 ? attempts.length : index + recoveryOffset + 2;
|
||||
const retryAttempts = attempts.slice(index, retryEndIndex);
|
||||
if (retryAttempts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
retries.push({
|
||||
attempts: retryAttempts.length,
|
||||
kind: "retry",
|
||||
method: first.request.method,
|
||||
route: first.request.route,
|
||||
statuses: retryAttempts.map((attempt) => attempt.response.status),
|
||||
});
|
||||
index = retryEndIndex - 1;
|
||||
}
|
||||
}
|
||||
const incrementalSyncRecords = orderedRecords.filter(
|
||||
(record) => record.sync?.since !== undefined,
|
||||
);
|
||||
return {
|
||||
errors: orderedRecords
|
||||
.filter((record) => record.response.status >= 400)
|
||||
.map((record) => {
|
||||
const error: MatrixQaScenarioRouteStateExpectation["errors"][number] = {
|
||||
method: record.request.method,
|
||||
route: record.request.route,
|
||||
status: record.response.status,
|
||||
};
|
||||
if (record.response.errcode) {
|
||||
error.errcode = record.response.errcode;
|
||||
}
|
||||
return error;
|
||||
}),
|
||||
ordering,
|
||||
retries,
|
||||
routes,
|
||||
state: Object.fromEntries(
|
||||
Object.entries(state).map(([key, values]) => [key, [...values].toSorted()]),
|
||||
) as Record<MatrixQaStateFamily, string[]>,
|
||||
syncTokens: {
|
||||
continuityObserved:
|
||||
incrementalSyncRecords.length > 0 &&
|
||||
incrementalSyncRecords.every((record) => record.sync?.continuity === true),
|
||||
incrementalRequests: incrementalSyncRecords.length,
|
||||
initialRequests: orderedRecords.filter(
|
||||
(record) => record.categories.includes("sync-token") && record.sync?.since === undefined,
|
||||
).length,
|
||||
responseTokens: orderedRecords.filter((record) => record.sync?.nextBatch !== undefined)
|
||||
.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractErrcode(body: Buffer, headers: Headers) {
|
||||
const parsed = parseJsonBody(body, headers);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return undefined;
|
||||
}
|
||||
const errcode = (parsed as { errcode?: unknown }).errcode;
|
||||
return typeof errcode === "string" ? errcode : undefined;
|
||||
}
|
||||
|
||||
function extractNextBatch(body: Buffer, headers: Headers) {
|
||||
const parsed = parseJsonBody(body, headers);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return undefined;
|
||||
}
|
||||
const nextBatch = (parsed as { next_batch?: unknown }).next_batch;
|
||||
return typeof nextBatch === "string" ? nextBatch : undefined;
|
||||
}
|
||||
|
||||
export async function startMatrixQaRecordingProxy(params: {
|
||||
targetBaseUrl: string;
|
||||
}): Promise<MatrixQaRecordingProxy> {
|
||||
let scenarioId = "setup";
|
||||
let sequence = 0;
|
||||
const records: MatrixQaInternalRecordedExchange[] = [];
|
||||
const syncTokensByPrincipal = new Map<string, Map<string, string>>();
|
||||
const observer: Required<MatrixQaFaultProxyObserver> = {
|
||||
createExchangeContext: () => ({ scenarioId, sequence: ++sequence }),
|
||||
onExchange(exchange: MatrixQaFaultProxyExchange) {
|
||||
recordExchange(exchange);
|
||||
},
|
||||
};
|
||||
const recordExchange = (exchange: MatrixQaFaultProxyExchange) => {
|
||||
const context =
|
||||
typeof exchange.context === "object" && exchange.context !== null
|
||||
? (exchange.context as { scenarioId?: unknown; sequence?: unknown })
|
||||
: undefined;
|
||||
const exchangeSequence = typeof context?.sequence === "number" ? context.sequence : ++sequence;
|
||||
const route = normalizeMatrixQaRoute(exchange.request.path);
|
||||
const exchangeScenarioId = route.endsWith("/sync")
|
||||
? scenarioId
|
||||
: typeof context?.scenarioId === "string"
|
||||
? context.scenarioId
|
||||
: "unattributed";
|
||||
const requestBody = buildBodyShape(exchange.request.body, exchange.request.headers, route);
|
||||
const responseBody = buildBodyShape(exchange.response.body, exchange.response.headers, route);
|
||||
const requestFields = extractStateFieldMarkers(exchange.request.body, exchange.request.headers);
|
||||
const responseFields = extractStateFieldMarkers(
|
||||
exchange.response.body,
|
||||
exchange.response.headers,
|
||||
);
|
||||
const syncPrincipal = exchange.request.bearerToken ?? "anonymous";
|
||||
const syncTokens = syncTokensByPrincipal.get(syncPrincipal) ?? new Map<string, string>();
|
||||
syncTokensByPrincipal.set(syncPrincipal, syncTokens);
|
||||
const sinceRaw = new URLSearchParams(exchange.request.search).get("since") ?? undefined;
|
||||
const since = sinceRaw ? (syncTokens.get(sinceRaw) ?? "sync-unknown") : undefined;
|
||||
const nextBatch = extractNextBatch(exchange.response.body, exchange.response.headers);
|
||||
if (nextBatch && !syncTokens.has(nextBatch)) {
|
||||
syncTokens.set(nextBatch, `sync-${syncTokens.size + 1}`);
|
||||
}
|
||||
const nextBatchAlias = nextBatch ? syncTokens.get(nextBatch) : undefined;
|
||||
const responseErrcode = extractErrcode(exchange.response.body, exchange.response.headers);
|
||||
const operationFingerprint = createHash("sha256")
|
||||
.update(exchange.request.method)
|
||||
.update("\0")
|
||||
.update(exchange.request.path)
|
||||
.update("\0")
|
||||
.update(exchange.request.search)
|
||||
.update("\0")
|
||||
.update(exchange.request.body)
|
||||
.update("\0")
|
||||
.update(exchange.request.bearerToken ?? "anonymous")
|
||||
.digest("hex");
|
||||
records.push({
|
||||
categories: resolveStateFamilies({ requestFields, responseFields, route }),
|
||||
request: {
|
||||
body: requestBody,
|
||||
method: exchange.request.method,
|
||||
query: buildRedactedQuery(exchange.request.search, syncTokens),
|
||||
route,
|
||||
},
|
||||
response: {
|
||||
body: responseBody,
|
||||
...(responseErrcode ? { errcode: responseErrcode } : {}),
|
||||
status: exchange.response.status,
|
||||
},
|
||||
scenarioId: exchangeScenarioId,
|
||||
sequence: exchangeSequence,
|
||||
operationFingerprint,
|
||||
...(route.endsWith("/sync")
|
||||
? {
|
||||
sync: {
|
||||
...(since ? { since } : {}),
|
||||
...(nextBatchAlias ? { nextBatch: nextBatchAlias } : {}),
|
||||
...(since && nextBatchAlias ? { continuity: since !== "sync-unknown" } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
const proxy = await startMatrixQaFaultProxy({
|
||||
targetBaseUrl: params.targetBaseUrl,
|
||||
rules: [],
|
||||
...observer,
|
||||
});
|
||||
|
||||
return {
|
||||
baseUrl: proxy.baseUrl,
|
||||
...observer,
|
||||
buildManifest({ generatedAt, requestedProfile, scenarioIds, substrate }) {
|
||||
const selectedIds = new Set(scenarioIds);
|
||||
const byScenario = new Map<string, MatrixQaInternalRecordedExchange[]>();
|
||||
for (const record of records) {
|
||||
const entries = byScenario.get(record.scenarioId) ?? [];
|
||||
entries.push(record);
|
||||
byScenario.set(record.scenarioId, entries);
|
||||
}
|
||||
const scenarios = Object.fromEntries(
|
||||
scenarioIds.map((id) => [id, buildExpectation(byScenario.get(id) ?? [])]),
|
||||
);
|
||||
const phases = Object.fromEntries(
|
||||
[...byScenario.entries()]
|
||||
.filter(([id]) => !selectedIds.has(id))
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([id, entries]) => [id, buildExpectation(entries)]),
|
||||
);
|
||||
return {
|
||||
generatedAt: generatedAt ?? new Date().toISOString(),
|
||||
phases,
|
||||
profile: {
|
||||
derivedFrom: "observed-request-response-traffic",
|
||||
id: MATRIX_QA_RECORDING_PROFILE,
|
||||
},
|
||||
requestedProfile,
|
||||
scenarios,
|
||||
substrate,
|
||||
};
|
||||
},
|
||||
records: () =>
|
||||
structuredClone(
|
||||
records
|
||||
.toSorted((left, right) => left.sequence - right.sequence)
|
||||
.map(({ operationFingerprint: _operationFingerprint, ...record }) => record),
|
||||
),
|
||||
setScenarioId(nextScenarioId) {
|
||||
scenarioId = nextScenarioId;
|
||||
},
|
||||
stop: () => proxy.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildExpectation,
|
||||
normalizeMatrixQaRoute,
|
||||
};
|
||||
export { testing as __testing };
|
||||
191
extensions/qa-matrix/src/substrate/request.test.ts
Normal file
191
extensions/qa-matrix/src/substrate/request.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// Qa Matrix tests cover request plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
|
||||
|
||||
describe("requestMatrixJson", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caps oversized request timeouts before creating the abort signal", async () => {
|
||||
const signal = AbortSignal.abort();
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(signal);
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(async () => Response.json({ ok: true }));
|
||||
|
||||
await requestMatrixJson({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ signal }));
|
||||
});
|
||||
|
||||
it("fails closed when the homeserver streams an over-cap response body", async () => {
|
||||
// Stream past the 16 MiB cap one chunk at a time so the fixture proves the
|
||||
// bound trips on the prefix and cancels the body instead of buffering it
|
||||
// all. `cancel()` flipping `canceled` is what real-behavior fail-closed
|
||||
// looks like: the stream is torn down, not drained.
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 32; // 32 MiB total, well past the 16 MiB limit
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(chunkSize)));
|
||||
if (reads >= chunkCount) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestMatrixJson({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
}),
|
||||
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
|
||||
|
||||
// Fail-closed proof: the read stopped before draining all 32 chunks and the
|
||||
// stream was canceled rather than fully buffered.
|
||||
expect(canceled).toBe(true);
|
||||
expect(reads).toBeLessThan(chunkCount);
|
||||
});
|
||||
|
||||
it("rejects an oversized error-status body instead of buffering it whole", async () => {
|
||||
// Even on a non-2xx status the cap must trip first: an attacker controlling
|
||||
// the homeserver could otherwise return a 500 with a multi-GiB body knowing
|
||||
// the helper only inspects `body.error` after fully reading it.
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 32;
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("b".repeat(chunkSize)));
|
||||
if (reads >= chunkCount) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestMatrixJson({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
}),
|
||||
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
|
||||
expect(canceled).toBe(true);
|
||||
expect(reads).toBeLessThan(chunkCount);
|
||||
});
|
||||
|
||||
it("still falls back to an empty body for malformed in-bounds JSON", async () => {
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(
|
||||
async () =>
|
||||
new Response("{ not valid json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestMatrixJson<{ ok?: boolean }>({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({});
|
||||
});
|
||||
|
||||
it("treats an empty in-bounds body as an empty object", async () => {
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(
|
||||
async () =>
|
||||
new Response("", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestMatrixJson<Record<string, unknown>>({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a normal in-bounds JSON body unchanged", async () => {
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(async () => Response.json({ user_id: "@qa:test" }));
|
||||
|
||||
const result = await requestMatrixJson<{ user_id: string }>({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/account/whoami",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ user_id: "@qa:test" });
|
||||
});
|
||||
|
||||
it("reads an in-bounds body just under the cap without tripping the bound", async () => {
|
||||
// Boundary guard: a body close to but under 16 MiB must parse normally so
|
||||
// the cap does not regress legitimate large-but-valid Matrix responses.
|
||||
const filler = "x".repeat(8 * 1024 * 1024); // 8 MiB string, well under cap
|
||||
const payload = JSON.stringify({ data: filler });
|
||||
const fetchImpl = vi.fn<MatrixQaFetchLike>(
|
||||
async () =>
|
||||
new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestMatrixJson<{ data: string }>({
|
||||
baseUrl: "https://matrix.example.test",
|
||||
endpoint: "/_matrix/client/v3/sync",
|
||||
fetchImpl,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.data).toHaveLength(filler.length);
|
||||
});
|
||||
});
|
||||
71
extensions/qa-matrix/src/substrate/request.ts
Normal file
71
extensions/qa-matrix/src/substrate/request.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Qa Matrix plugin module implements request behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
|
||||
export type MatrixQaFetchLike = typeof fetch;
|
||||
|
||||
// Cap how much of a Matrix homeserver response we buffer so a hostile or
|
||||
// misbehaving server cannot drive this process OOM with an unbounded body.
|
||||
// Shared across the QA substrate (also reused for media-upload reads in client.ts).
|
||||
export const MATRIX_QA_JSON_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type MatrixQaRequestResult<T> = {
|
||||
status: number;
|
||||
body: T;
|
||||
};
|
||||
|
||||
export async function requestMatrixJson<T>(params: {
|
||||
accessToken?: string;
|
||||
baseUrl: string;
|
||||
body?: unknown;
|
||||
endpoint: string;
|
||||
fetchImpl: MatrixQaFetchLike;
|
||||
method: "DELETE" | "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(resolveTimerTimeoutMs(params.timeoutMs, 20_000)),
|
||||
});
|
||||
// Read under a byte cap *before* the parse try/catch. The overflow error must
|
||||
// escape uncaught (fail-closed): swallowing it into `body = {}` would defeat
|
||||
// the bound and silently accept an oversized payload. Malformed but
|
||||
// in-bounds JSON still falls back to `{}` exactly as before.
|
||||
const bytes = await readResponseWithLimit(response, MATRIX_QA_JSON_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) => new Error(`Matrix homeserver response exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(new TextDecoder().decode(bytes)) 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,
|
||||
};
|
||||
}
|
||||
338
extensions/qa-matrix/src/substrate/sync.test.ts
Normal file
338
extensions/qa-matrix/src/substrate/sync.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Qa Matrix tests cover sync plugin behavior.
|
||||
import { describe, expect, it, vi } 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 nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(1);
|
||||
let result: Awaited<ReturnType<typeof waitForOptionalMatrixQaRoomEvent>>;
|
||||
try {
|
||||
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,
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
matched: false,
|
||||
since: "next-batch-2",
|
||||
});
|
||||
expect(observedEvents).toEqual([
|
||||
{
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$driver",
|
||||
sender: "@driver:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "hello",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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: {
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$sut",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "target",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
},
|
||||
matched: true,
|
||||
since: "next-batch-2",
|
||||
});
|
||||
expect(observedEvents).toEqual([
|
||||
{
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$sut",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "target",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
},
|
||||
{
|
||||
kind: "message",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$driver",
|
||||
sender: "@driver:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "trailing event",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.text",
|
||||
membership: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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;
|
||||
let markFetchStarted: () => void = () => {};
|
||||
const fetchStarted = new Promise<void>((resolve) => {
|
||||
markFetchStarted = resolve;
|
||||
});
|
||||
let releaseFetch: () => void = () => {};
|
||||
const fetchCanComplete = new Promise<void>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
});
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
markFetchStarted();
|
||||
await fetchCanComplete;
|
||||
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 waits = 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,
|
||||
}),
|
||||
]);
|
||||
|
||||
await fetchStarted;
|
||||
await Promise.resolve();
|
||||
releaseFetch();
|
||||
const [reply, notice] = await waits;
|
||||
|
||||
expect(reply.event.eventId).toBe("$reply");
|
||||
expect(notice).toEqual({
|
||||
event: {
|
||||
kind: "notice",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
eventId: "$notice",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
stateKey: undefined,
|
||||
type: "m.room.message",
|
||||
originServerTs: undefined,
|
||||
body: "notice",
|
||||
formattedBody: undefined,
|
||||
msgtype: "m.notice",
|
||||
membership: undefined,
|
||||
},
|
||||
matched: true,
|
||||
since: "next-batch-2",
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
228
extensions/qa-matrix/src/substrate/sync.ts
Normal file
228
extensions/qa-matrix/src/substrate/sync.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Qa Matrix plugin module implements sync behavior.
|
||||
import {
|
||||
findMatrixQaObservedEventMatch,
|
||||
normalizeMatrixQaObservedEvent,
|
||||
type MatrixQaObservedEvent,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
let didPoll = false;
|
||||
while (true) {
|
||||
const matched = findMatrixQaObservedEventMatch({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs >= waitParams.timeoutMs && (didPoll || waitParams.timeoutMs <= 0)) {
|
||||
roomObserver.cursorIndex = Math.max(roomObserver.cursorIndex, cursorIndex);
|
||||
return {
|
||||
matched: false,
|
||||
since: roomObserver.since ?? startSince,
|
||||
};
|
||||
}
|
||||
|
||||
cursorIndex = roomObserver.events.length;
|
||||
const remainingMs = Math.max(1_000, waitParams.timeoutMs - elapsedMs);
|
||||
await pollMatrixQaRoomObserver({
|
||||
...params,
|
||||
observedEvents: params.observedEvents,
|
||||
roomObserver,
|
||||
timeoutMs: remainingMs,
|
||||
});
|
||||
didPoll = true;
|
||||
}
|
||||
},
|
||||
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`);
|
||||
}
|
||||
107
extensions/qa-matrix/src/substrate/topology.ts
Normal file
107
extensions/qa-matrix/src/substrate/topology.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Qa Matrix plugin module implements topology behavior.
|
||||
export type MatrixQaParticipantRole = "driver" | "observer" | "sut";
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
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()],
|
||||
};
|
||||
}
|
||||
32
extensions/qa-matrix/src/windows-system-tools.test.ts
Normal file
32
extensions/qa-matrix/src/windows-system-tools.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Qa Matrix tests cover Windows system tool path resolution.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveMatrixQaWindowsSystem32ExePath,
|
||||
resolveMatrixQaWindowsSystemRoot,
|
||||
} from "./windows-system-tools.js";
|
||||
|
||||
describe("qa-matrix windows system tools", () => {
|
||||
it("resolves System32 executables from a trusted SystemRoot", () => {
|
||||
expect(resolveMatrixQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
|
||||
expect(
|
||||
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" }),
|
||||
).toBe("D:\\Windows\\System32\\taskkill.exe");
|
||||
});
|
||||
|
||||
it("falls back to the default Windows root when env roots are unsafe", () => {
|
||||
expect(
|
||||
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", {
|
||||
WINDIR: "\\\\attacker\\share",
|
||||
}),
|
||||
).toBe("C:\\Windows\\System32\\taskkill.exe");
|
||||
});
|
||||
|
||||
it("rejects non-basename System32 executable names", () => {
|
||||
expect(() => resolveMatrixQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
expect(() => resolveMatrixQaWindowsSystem32ExePath("taskkill")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
});
|
||||
});
|
||||
62
extensions/qa-matrix/src/windows-system-tools.ts
Normal file
62
extensions/qa-matrix/src/windows-system-tools.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Qa Matrix resolves Windows system tools without trusting PATH.
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
|
||||
|
||||
function getEnvValueCaseInsensitive(
|
||||
env: Record<string, string | undefined>,
|
||||
expectedKey: string,
|
||||
): string | undefined {
|
||||
const direct = env[expectedKey];
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
const expected = expectedKey.toUpperCase();
|
||||
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
|
||||
return actualKey ? env[actualKey] : undefined;
|
||||
}
|
||||
|
||||
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.includes("\0") ||
|
||||
trimmed.includes("\r") ||
|
||||
trimmed.includes("\n") ||
|
||||
trimmed.includes(";")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.win32.normalize(trimmed);
|
||||
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
|
||||
return null;
|
||||
}
|
||||
const parsed = path.win32.parse(normalized);
|
||||
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
|
||||
return null;
|
||||
}
|
||||
return normalized.replace(/[\\/]+$/u, "");
|
||||
}
|
||||
|
||||
export function resolveMatrixQaWindowsSystemRoot(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
return (
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
|
||||
DEFAULT_WINDOWS_SYSTEM_ROOT
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixQaWindowsSystem32ExePath(
|
||||
executableName: string,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
if (
|
||||
path.win32.basename(executableName) !== executableName ||
|
||||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
|
||||
) {
|
||||
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
|
||||
}
|
||||
return path.win32.join(resolveMatrixQaWindowsSystemRoot(env), "System32", executableName);
|
||||
}
|
||||
Reference in New Issue
Block a user