diff --git a/extensions/qa-matrix/src/substrate/client.test.ts b/extensions/qa-matrix/src/substrate/client.test.ts index b3152da8b4e..fd1711f03f9 100644 --- a/extensions/qa-matrix/src/substrate/client.test.ts +++ b/extensions/qa-matrix/src/substrate/client.test.ts @@ -5,6 +5,7 @@ import { provisionMatrixQaRoom, type MatrixQaObservedEvent, } from "./client.js"; +import { buildDefaultMatrixQaTopologySpec } from "./topology.js"; function resolveRequestUrl(input: RequestInfo | URL) { if (typeof input === "string") { @@ -308,6 +309,57 @@ describe("matrix driver client", () => { ); }); + it("issues Matrix room membership control requests for QA topology changes", async () => { + const requests: Array<{ body: Record; 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( @@ -401,16 +453,117 @@ describe("matrix driver client", () => { roomName: "OpenClaw Matrix QA", sutLocalpart: "qa-sut", fetchImpl, + topology: buildDefaultMatrixQaTopologySpec({ + defaultRoomName: "OpenClaw Matrix QA", + }), }); expect(result.roomId).toBe("!room:matrix-qa.test"); + expect(result.topology).toMatchObject({ + defaultRoomId: "!room:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + key: "main", + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@qa-driver:matrix-qa.test", + "@qa-observer:matrix-qa.test", + "@qa-sut:matrix-qa.test", + ], + requireMention: true, + roomId: "!room:matrix-qa.test", + }, + ], + }); expect(result.observer.userId).toBe("@qa-observer:matrix-qa.test"); expect(createRoomBodies).toEqual([ expect.objectContaining({ - invite: ["@qa-sut:matrix-qa.test", "@qa-observer:matrix-qa.test"], + invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"], is_direct: false, preset: "private_chat", }), ]); }); + + it("provisions direct-message topology rooms with Matrix direct-room flags", async () => { + const createRoomBodies: Array> = []; + const roomIds = ["!group:matrix-qa.test", "!dm:matrix-qa.test"]; + let registerCount = 0; + const fetchImpl: typeof fetch = async (input, init) => { + const url = resolveRequestUrl(input); + const body = parseJsonRequestBody(init); + if (url.endsWith("/_matrix/client/v3/register")) { + registerCount += 1; + const role = ["driver", "sut", "observer"][registerCount - 1]; + return new Response( + JSON.stringify({ + access_token: `token-${role}`, + user_id: `@qa-${role}:matrix-qa.test`, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url.endsWith("/_matrix/client/v3/createRoom")) { + createRoomBodies.push(body); + return new Response(JSON.stringify({ room_id: roomIds.shift() }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("/_matrix/client/v3/join/")) { + return new Response(JSON.stringify({ room_id: "!joined:matrix-qa.test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const result = await provisionMatrixQaRoom({ + baseUrl: "http://127.0.0.1:28008/", + driverLocalpart: "qa-driver", + observerLocalpart: "qa-observer", + registrationToken: "reg-token", + roomName: "unused", + sutLocalpart: "qa-sut", + fetchImpl, + topology: { + defaultRoomKey: "group", + rooms: [ + { + key: "group", + kind: "group", + members: ["driver", "observer", "sut"], + name: "Matrix Group", + requireMention: true, + }, + { + key: "sut-dm", + kind: "dm", + members: ["driver", "sut"], + name: "Matrix Driver/SUT DM", + }, + ], + }, + }); + + expect(result.topology.rooms).toMatchObject([ + { key: "group", kind: "group", roomId: "!group:matrix-qa.test", requireMention: true }, + { key: "sut-dm", kind: "dm", roomId: "!dm:matrix-qa.test", requireMention: false }, + ]); + expect(createRoomBodies).toEqual([ + expect.objectContaining({ + invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"], + is_direct: false, + name: "Matrix Group", + }), + expect.objectContaining({ + invite: ["@qa-sut:matrix-qa.test"], + is_direct: true, + name: "Matrix Driver/SUT DM", + }), + ]); + }); }); diff --git a/extensions/qa-matrix/src/substrate/client.ts b/extensions/qa-matrix/src/substrate/client.ts index 0d6bd77e67a..ef5158eebfc 100644 --- a/extensions/qa-matrix/src/substrate/client.ts +++ b/extensions/qa-matrix/src/substrate/client.ts @@ -1,5 +1,12 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + findMatrixQaProvisionedRoom, + type MatrixQaParticipantRole, + type MatrixQaProvisionedTopology, + type MatrixQaTopologyRoomSpec, + type MatrixQaTopologySpec, +} from "./topology.js"; type FetchLike = typeof fetch; @@ -115,6 +122,7 @@ export type MatrixQaProvisionResult = { observer: MatrixQaRegisteredAccount; roomId: string; sut: MatrixQaRegisteredAccount; + topology: MatrixQaProvisionedTopology; }; export type MatrixQaRoomEventWaitResult = @@ -488,7 +496,7 @@ export function createMatrixQaClient(params: { } return { - async createPrivateRoom(opts: { inviteUserIds: string[]; name: string }) { + async createPrivateRoom(opts: { inviteUserIds: string[]; isDirect?: boolean; name: string }) { const result = await requestMatrixJson({ accessToken: params.accessToken, baseUrl: params.baseUrl, @@ -502,7 +510,7 @@ export function createMatrixQaClient(params: { }, ], invite: opts.inviteUserIds, - is_direct: false, + is_direct: opts.isDirect === true, name: opts.name, preset: "private_chat", }, @@ -618,6 +626,41 @@ export function createMatrixQaClient(params: { }); return result.body.room_id?.trim() || roomId; }, + async inviteUserToRoom(opts: { roomId: string; userId: string }) { + await requestMatrixJson>({ + 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>({ + 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>({ + accessToken: params.accessToken, + baseUrl: params.baseUrl, + body: {}, + endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`, + fetchImpl, + method: "POST", + }); + }, waitForOptionalRoomEvent, async waitForRoomEvent(opts: { observedEvents: MatrixQaObservedEvent[]; @@ -659,9 +702,85 @@ async function joinRoomWithRetry(params: { 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, + memberRoles: MatrixQaParticipantRole[], +) { + const uniqueRoles = [...new Set(memberRoles)]; + if (uniqueRoles.length === 0) { + throw new Error("Matrix QA room provisioning requires at least one member"); + } + return uniqueRoles.map((role) => ({ + role, + account: accounts[role], + })); +} + +async function provisionMatrixQaTopology(params: { + accounts: Record; + baseUrl: string; + fetchImpl?: FetchLike; + spec: MatrixQaTopologySpec; +}): Promise { + 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({ + inviteUserIds: invitees.map((entry) => entry.account.userId), + isDirect: room.kind === "dm", + name: room.name, + }); + for (const invitee of invitees) { + await joinRoomWithRetry({ + accessToken: invitee.account.accessToken, + baseUrl: params.baseUrl, + fetchImpl: params.fetchImpl, + roomId, + }); + } + rooms.push({ + 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?: FetchLike; + topology?: MatrixQaTopologySpec; roomName: string; driverLocalpart: string; observerLocalpart: string; @@ -690,32 +809,35 @@ export async function provisionMatrixQaRoom(params: { password: `observer-${randomUUID()}`, registrationToken: params.registrationToken, }); - const driverClient = createMatrixQaClient({ - accessToken: driver.accessToken, + const topology = await provisionMatrixQaTopology({ + accounts: { + driver, + observer, + sut, + }, baseUrl: params.baseUrl, fetchImpl: params.fetchImpl, - }); - const roomId = await driverClient.createPrivateRoom({ - inviteUserIds: [sut.userId, observer.userId], - name: params.roomName, - }); - await joinRoomWithRetry({ - accessToken: sut.accessToken, - baseUrl: params.baseUrl, - fetchImpl: params.fetchImpl, - roomId, - }); - await joinRoomWithRetry({ - accessToken: observer.accessToken, - baseUrl: params.baseUrl, - fetchImpl: params.fetchImpl, - roomId, + 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, + roomId: topology.defaultRoomId, sut, + topology, } satisfies MatrixQaProvisionResult; } diff --git a/extensions/qa-matrix/src/substrate/config.test.ts b/extensions/qa-matrix/src/substrate/config.test.ts new file mode 100644 index 00000000000..8ffc48f3ddd --- /dev/null +++ b/extensions/qa-matrix/src/substrate/config.test.ts @@ -0,0 +1,125 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { buildMatrixQaConfig } from "./config.js"; + +describe("matrix qa config", () => { + const topology = { + 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/", + sutAccessToken: "sut-token", + sutAccountId: "sut", + sutUserId: "@sut:matrix-qa.test", + topology, + }); + + expect(next.channels?.matrix?.accounts?.sut).toMatchObject({ + dm: { + allowFrom: ["@driver:matrix-qa.test"], + enabled: true, + policy: "allowlist", + }, + groupAllowFrom: ["@driver:matrix-qa.test"], + groupPolicy: "allowlist", + groups: { + "!main:matrix-qa.test": { enabled: true, requireMention: true }, + "!secondary:matrix-qa.test": { enabled: true, requireMention: true }, + }, + replyToMode: "off", + threadReplies: "inbound", + }); + }); + + it("applies room-keyed Matrix QA config overrides", () => { + const next = buildMatrixQaConfig({} as OpenClawConfig, { + driverUserId: "@driver:matrix-qa.test", + homeserver: "http://127.0.0.1:28008/", + overrides: { + groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"], + groupsByKey: { + secondary: { + requireMention: false, + }, + }, + replyToMode: "all", + threadReplies: "always", + }, + sutAccessToken: "sut-token", + sutAccountId: "sut", + sutUserId: "@sut:matrix-qa.test", + topology, + }); + + expect(next.channels?.matrix?.accounts?.sut).toMatchObject({ + groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"], + groups: { + "!main:matrix-qa.test": { enabled: true, requireMention: true }, + "!secondary:matrix-qa.test": { enabled: true, requireMention: false }, + }, + replyToMode: "all", + threadReplies: "always", + }); + }); + + it("rejects unknown room-key overrides", () => { + expect(() => + buildMatrixQaConfig({} as OpenClawConfig, { + driverUserId: "@driver:matrix-qa.test", + homeserver: "http://127.0.0.1:28008/", + 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"'); + }); +}); diff --git a/extensions/qa-matrix/src/substrate/config.ts b/extensions/qa-matrix/src/substrate/config.ts new file mode 100644 index 00000000000..d838f06a932 --- /dev/null +++ b/extensions/qa-matrix/src/substrate/config.ts @@ -0,0 +1,169 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-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"; + +export type MatrixQaGroupConfigOverrides = { + enabled?: boolean; + requireMention?: boolean; +}; + +export type MatrixQaDmConfigOverrides = { + allowFrom?: string[]; + enabled?: boolean; + policy?: MatrixQaDmPolicy; + sessionScope?: "per-room" | "per-user"; + threadReplies?: MatrixQaThreadRepliesMode; +}; + +export type MatrixQaConfigOverrides = { + autoJoin?: "allowlist" | "always" | "off"; + blockStreaming?: boolean; + dm?: MatrixQaDmConfigOverrides; + encryption?: boolean; + groupAllowFrom?: string[]; + groupPolicy?: MatrixQaGroupPolicy; + groupsByKey?: Record; + replyToMode?: MatrixQaReplyToMode; + streaming?: "off" | "partial" | "quiet" | boolean; + threadReplies?: MatrixQaThreadRepliesMode; +}; + +function resolveMatrixQaGroupEntries(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.roomId, + { + enabled: override?.enabled ?? true, + requireMention: override?.requireMention ?? room.requireMention, + }, + ]; + }), + ); +} + +function resolveMatrixQaDmAllowFrom(params: { + driverUserId: string; + overrides?: MatrixQaConfigOverrides; + sutUserId: string; + topology: MatrixQaProvisionedTopology; +}) { + if (params.overrides?.dm?.allowFrom) { + return [...params.overrides.dm.allowFrom]; + } + const dmAllowFrom = [ + ...new Set( + params.topology.rooms + .filter((room) => room.kind === "dm") + .flatMap((room) => room.memberUserIds.filter((userId) => userId !== params.sutUserId)), + ), + ]; + return dmAllowFrom.length > 0 ? dmAllowFrom : [params.driverUserId]; +} + +function resolveMatrixQaDmConfig(params: { + driverUserId: string; + overrides?: MatrixQaConfigOverrides; + sutUserId: string; + topology: MatrixQaProvisionedTopology; +}) { + const hasDmRooms = params.topology.rooms.some((room) => room.kind === "dm"); + const dmOverrides = params.overrides?.dm; + + if (!hasDmRooms && dmOverrides?.enabled !== true) { + return { enabled: false }; + } + + return { + allowFrom: resolveMatrixQaDmAllowFrom(params), + enabled: dmOverrides?.enabled ?? true, + policy: dmOverrides?.policy ?? "allowlist", + ...(dmOverrides?.sessionScope ? { sessionScope: dmOverrides.sessionScope } : {}), + ...(dmOverrides?.threadReplies ? { threadReplies: dmOverrides.threadReplies } : {}), + }; +} + +export function buildMatrixQaConfig( + baseCfg: OpenClawConfig, + params: { + driverUserId: string; + homeserver: string; + overrides?: MatrixQaConfigOverrides; + sutAccessToken: string; + sutAccountId: string; + sutDeviceId?: string; + sutUserId: string; + topology: MatrixQaProvisionedTopology; + }, +): OpenClawConfig { + const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "matrix"])]; + const groups = resolveMatrixQaGroupEntries({ + overrides: params.overrides, + topology: params.topology, + }); + + return { + ...baseCfg, + plugins: { + ...baseCfg.plugins, + allow: pluginAllow, + entries: { + ...baseCfg.plugins?.entries, + matrix: { enabled: true }, + }, + }, + channels: { + ...baseCfg.channels, + matrix: { + ...baseCfg.channels?.matrix, + enabled: true, + defaultAccount: params.sutAccountId, + accounts: { + ...baseCfg.channels?.matrix?.accounts, + [params.sutAccountId]: { + accessToken: params.sutAccessToken, + ...(params.sutDeviceId ? { deviceId: params.sutDeviceId } : {}), + dm: resolveMatrixQaDmConfig(params), + enabled: true, + encryption: params.overrides?.encryption ?? false, + groupAllowFrom: params.overrides?.groupAllowFrom ?? [params.driverUserId], + groupPolicy: params.overrides?.groupPolicy ?? "allowlist", + ...(Object.keys(groups).length > 0 ? { groups } : {}), + homeserver: params.homeserver, + network: { + dangerouslyAllowPrivateNetwork: true, + }, + replyToMode: params.overrides?.replyToMode ?? "off", + threadReplies: params.overrides?.threadReplies ?? "inbound", + userId: params.sutUserId, + ...(params.overrides?.autoJoin ? { autoJoin: params.overrides.autoJoin } : {}), + ...(params.overrides?.blockStreaming !== undefined + ? { blockStreaming: params.overrides.blockStreaming } + : {}), + ...(params.overrides?.streaming !== undefined + ? { streaming: params.overrides.streaming } + : {}), + }, + }, + }, + }, + }; +} diff --git a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts index 1a907f63872..784a1005b88 100644 --- a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts +++ b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts @@ -84,6 +84,10 @@ describe("matrix harness runtime", () => { expect(result.stopCommand).toBe( `docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans`, ); + await result.restartService(); + expect(calls).toContain( + `docker compose -f ${outputDir}/docker-compose.matrix-qa.yml restart matrix-qa-homeserver @/repo/openclaw`, + ); } finally { await rm(outputDir, { recursive: true, force: true }); } diff --git a/extensions/qa-matrix/src/substrate/harness.runtime.ts b/extensions/qa-matrix/src/substrate/harness.runtime.ts index 5b21e34d823..069c81c5d2e 100644 --- a/extensions/qa-matrix/src/substrate/harness.runtime.ts +++ b/extensions/qa-matrix/src/substrate/harness.runtime.ts @@ -39,6 +39,7 @@ export type MatrixQaHarnessFiles = { export type MatrixQaHarness = MatrixQaHarnessFiles & { baseUrl: string; + restartService(): Promise; stopCommand: string; stop(): Promise; }; @@ -248,9 +249,34 @@ export async function startMatrixQaHarness( sleepImpl, }); + const waitForReady = async () => { + await sleepImpl(1_000); + await waitForDockerServiceHealth( + MATRIX_QA_SERVICE, + files.composeFile, + repoRoot, + runCommand, + sleepImpl, + ); + await waitForHealth(buildVersionsUrl(baseUrl), { + label: "Matrix homeserver", + composeFile: files.composeFile, + fetchImpl, + sleepImpl, + }); + }; + return { ...files, baseUrl, + async restartService() { + await runCommand( + "docker", + ["compose", "-f", files.composeFile, "restart", MATRIX_QA_SERVICE], + repoRoot, + ); + await waitForReady(); + }, stopCommand: `docker compose -f ${files.composeFile} down --remove-orphans`, async stop() { await runCommand( diff --git a/extensions/qa-matrix/src/substrate/topology.ts b/extensions/qa-matrix/src/substrate/topology.ts new file mode 100644 index 00000000000..bced2982666 --- /dev/null +++ b/extensions/qa-matrix/src/substrate/topology.ts @@ -0,0 +1,102 @@ +export type MatrixQaParticipantRole = "driver" | "observer" | "sut"; + +export type MatrixQaRoomKind = "dm" | "group"; + +export type MatrixQaTopologyRoomSpec = { + key: string; + kind: MatrixQaRoomKind; + members: MatrixQaParticipantRole[]; + name: string; + requireMention?: boolean; +}; + +export type MatrixQaTopologySpec = { + defaultRoomKey: string; + rooms: MatrixQaTopologyRoomSpec[]; +}; + +export type MatrixQaProvisionedRoom = { + 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.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: [ + { + 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(); + 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()], + }; +}