Skip to content

Commit 3175d8e

Browse files
authored
refactor(qa): drive Matrix lifecycle through channel drivers (#101055)
* test(matrix): add substrate lifecycle contract * fix(matrix): satisfy lifecycle dependency gates * refactor(matrix): keep Crabline lifecycle in adapter * refactor(qa): select Matrix lifecycle by driver lane * refactor(matrix): keep lifecycle helpers test-only * refactor(qa): drive Matrix lifecycle through channel drivers
1 parent 4a9fe26 commit 3175d8e

4 files changed

Lines changed: 269 additions & 0 deletions

File tree

extensions/qa-lab/runtime-api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,11 @@ export {
4040
setQaChannelRuntime,
4141
} from "./src/runtime-api.js";
4242
export { startQaLiveLaneGateway } from "./src/live-transports/shared/live-gateway.runtime.js";
43+
export {
44+
createQaChannelDriverLifecycle,
45+
runQaChannelDriverLifecycleScenarios,
46+
type QaChannelDriverLifecycle,
47+
type QaChannelDriverLifecycleScenarioId,
48+
type QaChannelDriverLifecycleState,
49+
type QaChannelDriverRuntime,
50+
} from "./src/channel-driver-lifecycle.js";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
createQaChannelDriverLifecycle,
4+
runQaChannelDriverLifecycleScenarios,
5+
} from "./channel-driver-lifecycle.js";
6+
import type { QaTransportAdapter } from "./qa-transport.js";
7+
8+
describe("QA channel driver lifecycle", () => {
9+
it("runs all lifecycle scenarios through create and cleanup", async () => {
10+
const runtimes: Array<{ adapter: QaTransportAdapter; cleanup: () => Promise<void> }> = [];
11+
const createAdapter = vi.fn(async () => {
12+
const id = runtimes.length + 1;
13+
const runtime = {
14+
adapter: { id: String(id) } as QaTransportAdapter,
15+
cleanup: vi.fn(async () => {}),
16+
};
17+
runtimes.push(runtime);
18+
return runtime;
19+
});
20+
const lifecycle = createQaChannelDriverLifecycle(
21+
{ channelId: "matrix", driver: "live", outputDir: "/tmp/matrix-lifecycle" },
22+
{ createAdapter, listAdapterFactories: () => [] },
23+
);
24+
const probedIds: number[] = [];
25+
const stoppedIds: number[] = [];
26+
27+
const results = await runQaChannelDriverLifecycleScenarios({
28+
async assertStopped(runtime) {
29+
expect(runtime.cleanup).toHaveBeenCalledOnce();
30+
stoppedIds.push(Number(runtime.adapter.id));
31+
},
32+
lifecycle,
33+
async probe(runtime) {
34+
probedIds.push(Number(runtime.adapter.id));
35+
},
36+
});
37+
38+
expect(results).toEqual(["cold-start", "idempotent-start", "restart", "stop", "resume"]);
39+
expect(probedIds).toEqual([1, 1, 2, 3]);
40+
expect(stoppedIds).toEqual([1, 2]);
41+
expect(createAdapter).toHaveBeenCalledTimes(3);
42+
expect(lifecycle.state).toEqual({ runtime: runtimes[2], status: "running" });
43+
});
44+
45+
it("keeps the running adapter when cleanup fails", async () => {
46+
const runtime = {
47+
adapter: {} as QaTransportAdapter,
48+
cleanup: vi.fn(async () => {
49+
throw new Error("cleanup failed");
50+
}),
51+
};
52+
const lifecycle = createQaChannelDriverLifecycle(
53+
{ channelId: "matrix", driver: "live", outputDir: "/tmp/matrix-lifecycle" },
54+
{ createAdapter: async () => runtime, listAdapterFactories: () => [] },
55+
);
56+
57+
await lifecycle.start();
58+
await expect(lifecycle.stop()).rejects.toThrow("cleanup failed");
59+
expect(lifecycle.state).toEqual({ runtime, status: "running" });
60+
});
61+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createQaBusState } from "./bus-state.js";
2+
import {
3+
createQaTransportAdapter,
4+
type QaTransportAdapterFactory,
5+
type QaTransportAdapterFactoryResult,
6+
type QaTransportDriver,
7+
} from "./qa-transport-registry.js";
8+
9+
export type QaChannelDriverRuntime = QaTransportAdapterFactoryResult;
10+
11+
export type QaChannelDriverLifecycleState =
12+
| { status: "stopped" }
13+
| { runtime: QaChannelDriverRuntime; status: "running" };
14+
15+
export type QaChannelDriverLifecycle = {
16+
readonly state: QaChannelDriverLifecycleState;
17+
restart(): Promise<QaChannelDriverRuntime>;
18+
start(): Promise<QaChannelDriverRuntime>;
19+
stop(): Promise<void>;
20+
};
21+
22+
export type QaChannelDriverLifecycleScenarioId =
23+
| "cold-start"
24+
| "idempotent-start"
25+
| "restart"
26+
| "stop"
27+
| "resume";
28+
29+
type QaChannelDriverLifecycleDeps = {
30+
createAdapter?: typeof createQaTransportAdapter;
31+
listAdapterFactories?: () =>
32+
| readonly QaTransportAdapterFactory[]
33+
| Promise<readonly QaTransportAdapterFactory[]>;
34+
};
35+
36+
async function listAdapterFactories(): Promise<readonly QaTransportAdapterFactory[]> {
37+
const { listLiveTransportQaAdapterFactories } = await import("./live-transports/cli.js");
38+
return listLiveTransportQaAdapterFactories();
39+
}
40+
41+
export function createQaChannelDriverLifecycle(
42+
params: {
43+
channelId: string;
44+
driver: QaTransportDriver;
45+
outputDir: string;
46+
},
47+
deps: QaChannelDriverLifecycleDeps = {},
48+
): QaChannelDriverLifecycle {
49+
const createAdapter = deps.createAdapter ?? createQaTransportAdapter;
50+
const discoverAdapterFactories = deps.listAdapterFactories ?? listAdapterFactories;
51+
let state: QaChannelDriverLifecycleState = { status: "stopped" };
52+
53+
const lifecycle: QaChannelDriverLifecycle = {
54+
get state() {
55+
return state;
56+
},
57+
async restart() {
58+
await lifecycle.stop();
59+
return await lifecycle.start();
60+
},
61+
async start() {
62+
if (state.status === "running") {
63+
return state.runtime;
64+
}
65+
const runtime = await createAdapter(
66+
{
67+
...params,
68+
state: createQaBusState(),
69+
},
70+
await discoverAdapterFactories(),
71+
);
72+
state = { runtime, status: "running" };
73+
return runtime;
74+
},
75+
async stop() {
76+
if (state.status === "stopped") {
77+
return;
78+
}
79+
const runtime = state.runtime;
80+
await runtime.cleanup();
81+
state = { status: "stopped" };
82+
},
83+
};
84+
85+
return lifecycle;
86+
}
87+
88+
export async function runQaChannelDriverLifecycleScenarios(params: {
89+
assertStopped(runtime: QaChannelDriverRuntime): Promise<void>;
90+
lifecycle: QaChannelDriverLifecycle;
91+
probe(runtime: QaChannelDriverRuntime): Promise<void>;
92+
}): Promise<QaChannelDriverLifecycleScenarioId[]> {
93+
const results: QaChannelDriverLifecycleScenarioId[] = [];
94+
95+
const coldStart = await params.lifecycle.start();
96+
await params.probe(coldStart);
97+
results.push("cold-start");
98+
99+
const idempotentStart = await params.lifecycle.start();
100+
if (idempotentStart !== coldStart) {
101+
throw new Error("channel driver start replaced an already-running adapter");
102+
}
103+
await params.probe(idempotentStart);
104+
results.push("idempotent-start");
105+
106+
const restarted = await params.lifecycle.restart();
107+
await params.assertStopped(idempotentStart);
108+
await params.probe(restarted);
109+
results.push("restart");
110+
111+
await params.lifecycle.stop();
112+
await params.assertStopped(restarted);
113+
results.push("stop");
114+
115+
const resumed = await params.lifecycle.start();
116+
await params.probe(resumed);
117+
results.push("resume");
118+
119+
return results;
120+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import path from "node:path";
2+
import { pathToFileURL } from "node:url";
3+
import { withTempDir } from "openclaw/plugin-sdk/test-env";
4+
import { describe, expect, it } from "vitest";
5+
import type { QaChannelDriverRuntime } from "./channel-driver-lifecycle.js";
6+
import type { QaTransportDriver } from "./qa-transport-registry.js";
7+
8+
const EXPECTED_SCENARIOS = ["cold-start", "idempotent-start", "restart", "stop", "resume"];
9+
const MATRIX_ROOM_ID = "!matrix-lifecycle:matrix.test";
10+
const MATRIX_CHANNEL_DRIVERS = ["live", "crabline"] as const satisfies readonly QaTransportDriver[];
11+
12+
type QaLabRuntimeApi = typeof import("../runtime-api.js");
13+
14+
async function loadQaLabRuntimeApi(): Promise<QaLabRuntimeApi> {
15+
const runtimeApiPath = path.join(process.cwd(), "dist", "extensions", "qa-lab", "runtime-api.js");
16+
return (await import(pathToFileURL(runtimeApiPath).href)) as QaLabRuntimeApi;
17+
}
18+
19+
async function withChannelDriverOutputDir(
20+
driver: QaTransportDriver,
21+
run: (outputDir: string) => Promise<void>,
22+
) {
23+
let runError: Error | undefined;
24+
try {
25+
await withTempDir(`matrix-${driver}-lifecycle-`, async (outputDir) => {
26+
try {
27+
await run(outputDir);
28+
} catch (error) {
29+
runError = error instanceof Error ? error : new Error(String(error));
30+
throw error;
31+
}
32+
});
33+
} catch (error) {
34+
if (runError) {
35+
throw runError;
36+
}
37+
if ((error as NodeJS.ErrnoException).code !== "EACCES") {
38+
throw error;
39+
}
40+
}
41+
}
42+
43+
function sendMatrixLifecycleProbe(runtime: QaChannelDriverRuntime, sequence: number) {
44+
return runtime.adapter.sendInbound({
45+
conversation: { id: MATRIX_ROOM_ID, kind: "group" },
46+
senderId: "@lifecycle-driver:matrix.test",
47+
senderName: "Matrix Lifecycle Driver",
48+
text: `Matrix lifecycle probe ${sequence}`,
49+
});
50+
}
51+
52+
describe.each(MATRIX_CHANNEL_DRIVERS)("Matrix %s channel driver lifecycle", (driver) => {
53+
it("passes all five scenarios through the selected QA transport adapter", async () => {
54+
await withChannelDriverOutputDir(driver, async (outputDir) => {
55+
const runtimeApi = await loadQaLabRuntimeApi();
56+
const lifecycle = runtimeApi.createQaChannelDriverLifecycle({
57+
channelId: "matrix",
58+
driver,
59+
outputDir,
60+
});
61+
let sequence = 0;
62+
63+
try {
64+
const results = await runtimeApi.runQaChannelDriverLifecycleScenarios({
65+
async assertStopped(runtime) {
66+
await expect(sendMatrixLifecycleProbe(runtime, ++sequence)).rejects.toThrow();
67+
},
68+
lifecycle,
69+
async probe(runtime) {
70+
await sendMatrixLifecycleProbe(runtime, ++sequence);
71+
},
72+
});
73+
74+
expect(results).toEqual(EXPECTED_SCENARIOS);
75+
} finally {
76+
await lifecycle.stop();
77+
}
78+
});
79+
}, 240_000);
80+
});

0 commit comments

Comments
 (0)