Skip to content

Commit edfa074

Browse files
authored
Tests: align pnpm test expectations with main (#67001)
Merged via squash. Prepared head SHA: 29c8068 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent 8dd1abe commit edfa074

15 files changed

Lines changed: 301 additions & 44 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
1111
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
1212
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
13+
- Tests: align pnpm test expectations with main (#67001). Thanks @hxy91819
1314

1415
### Fixes
1516

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createServer } from "node:http";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import { getQaBusState, pollQaBus } from "./bus-client.js";
4+
5+
async function startJsonServer(
6+
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
7+
) {
8+
const server = createServer((req, res) => {
9+
const response = handler({ url: req.url });
10+
res.writeHead(response.statusCode ?? 200, {
11+
"content-type": "application/json; charset=utf-8",
12+
});
13+
res.end(response.body);
14+
});
15+
16+
await new Promise<void>((resolve, reject) => {
17+
server.once("error", reject);
18+
server.listen(0, "127.0.0.1", () => resolve());
19+
});
20+
21+
const address = server.address();
22+
if (!address || typeof address === "string") {
23+
throw new Error("test server failed to bind");
24+
}
25+
26+
return {
27+
baseUrl: `http://127.0.0.1:${address.port}`,
28+
async stop() {
29+
await new Promise<void>((resolve, reject) => {
30+
server.close((error) => (error ? reject(error) : resolve()));
31+
});
32+
},
33+
};
34+
}
35+
36+
describe("qa-bus client", () => {
37+
const stops: Array<() => Promise<void>> = [];
38+
39+
afterEach(async () => {
40+
await Promise.all(stops.splice(0).map((stop) => stop()));
41+
});
42+
43+
it("rejects malformed JSON responses instead of throwing from the stream callback", async () => {
44+
const server = await startJsonServer(() => ({
45+
body: '{"cursor":1,"events":[',
46+
}));
47+
stops.push(server.stop);
48+
49+
await expect(
50+
pollQaBus({
51+
baseUrl: server.baseUrl,
52+
accountId: "acct-a",
53+
cursor: 0,
54+
timeoutMs: 0,
55+
}),
56+
).rejects.toThrow(SyntaxError);
57+
});
58+
59+
it("preserves baseUrl path prefixes when composing bus URLs", async () => {
60+
const server = await startJsonServer((req) => ({
61+
statusCode: req.url === "/qa-bus/v1/state" ? 200 : 404,
62+
body:
63+
req.url === "/qa-bus/v1/state"
64+
? JSON.stringify({
65+
cursor: 1,
66+
conversations: [],
67+
threads: [],
68+
messages: [],
69+
events: [],
70+
})
71+
: JSON.stringify({ error: `unexpected path: ${req.url}` }),
72+
}));
73+
stops.push(server.stop);
74+
75+
await expect(getQaBusState(`${server.baseUrl}/qa-bus`)).resolves.toMatchObject({
76+
cursor: 1,
77+
events: [],
78+
});
79+
});
80+
});

extensions/qa-channel/src/bus-client.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import http from "node:http";
2+
import https from "node:https";
13
import type {
24
QaBusConversation,
35
QaBusEvent,
@@ -32,27 +34,78 @@ export type {
3234

3335
type JsonResult<T> = Promise<T>;
3436

37+
function buildQaBusUrl(baseUrl: string, path: string): URL {
38+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
39+
return new URL(path.replace(/^\/+/, ""), normalizedBaseUrl);
40+
}
41+
3542
async function postJson<T>(
3643
baseUrl: string,
3744
path: string,
3845
body: unknown,
3946
signal?: AbortSignal,
4047
): JsonResult<T> {
41-
const response = await fetch(`${baseUrl}${path}`, {
42-
method: "POST",
43-
headers: {
44-
"content-type": "application/json",
45-
},
46-
body: JSON.stringify(body),
47-
signal,
48+
const url = buildQaBusUrl(baseUrl, path);
49+
const payload = JSON.stringify(body);
50+
const client = url.protocol === "https:" ? https : http;
51+
52+
return await new Promise<T>((resolve, reject) => {
53+
const abortError = () =>
54+
Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
55+
if (signal?.aborted) {
56+
reject(abortError());
57+
return;
58+
}
59+
60+
const request = client.request(
61+
url,
62+
{
63+
method: "POST",
64+
headers: {
65+
"content-type": "application/json",
66+
"content-length": Buffer.byteLength(payload),
67+
connection: "close",
68+
},
69+
},
70+
(response) => {
71+
const chunks: Buffer[] = [];
72+
response.on("data", (chunk) => {
73+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
74+
});
75+
response.on("end", () => {
76+
const text = Buffer.concat(chunks).toString("utf8");
77+
let parsed: T | { error?: string };
78+
try {
79+
parsed = text ? (JSON.parse(text) as T | { error?: string }) : ({} as T);
80+
} catch (error) {
81+
reject(error);
82+
return;
83+
}
84+
if ((response.statusCode ?? 500) < 200 || (response.statusCode ?? 500) >= 300) {
85+
const error =
86+
typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : undefined;
87+
reject(new Error(error || `qa-bus request failed: ${response.statusCode ?? 500}`));
88+
return;
89+
}
90+
resolve(parsed as T);
91+
});
92+
response.on("error", reject);
93+
},
94+
);
95+
96+
const onAbort = () => {
97+
request.destroy(abortError());
98+
};
99+
signal?.addEventListener("abort", onAbort, { once: true });
100+
request.on("error", (error) => {
101+
signal?.removeEventListener("abort", onAbort);
102+
reject(error);
103+
});
104+
request.on("close", () => {
105+
signal?.removeEventListener("abort", onAbort);
106+
});
107+
request.end(payload);
48108
});
49-
const payload = (await response.json()) as T | { error?: string };
50-
if (!response.ok) {
51-
const error =
52-
typeof payload === "object" && payload && "error" in payload ? payload.error : undefined;
53-
throw new Error(error || `qa-bus request failed: ${response.status}`);
54-
}
55-
return payload as T;
56109
}
57110

58111
export function normalizeQaTarget(raw: string): string | undefined {
@@ -218,7 +271,7 @@ export async function injectQaBusInboundMessage(params: {
218271
}
219272

220273
export async function getQaBusState(baseUrl: string): Promise<QaBusStateSnapshot> {
221-
const response = await fetch(`${baseUrl}/v1/state`);
274+
const response = await fetch(buildQaBusUrl(baseUrl, "/v1/state"));
222275
if (!response.ok) {
223276
throw new Error(`qa-bus request failed: ${response.status}`);
224277
}

extensions/qa-channel/src/channel.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2-
import { describe, expect, it } from "vitest";
2+
import { afterEach, describe, expect, it } from "vitest";
33
import { extractToolPayload } from "../../../src/infra/outbound/tool-payload.js";
4+
import {
5+
resetPluginRuntimeStateForTest,
6+
setActivePluginRegistry,
7+
} from "../../../src/plugins/runtime.js";
8+
import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
49
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
510
import { createQaBusState, startQaBusServer } from "../../qa-lab/api.js";
6-
import { qaChannelPlugin } from "../api.js";
7-
import { setQaChannelRuntime } from "../api.js";
11+
import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
12+
13+
afterEach(() => {
14+
resetPluginRuntimeStateForTest();
15+
});
16+
17+
function installQaChannelTestRegistry() {
18+
setActivePluginRegistry(
19+
createTestRegistry([{ pluginId: "qa-channel", plugin: qaChannelPlugin, source: "test" }]),
20+
);
21+
}
822

923
function createMockQaRuntime(params?: {
1024
onDispatch?: (ctx: Record<string, unknown>) => void;
@@ -71,6 +85,7 @@ function createMockQaRuntime(params?: {
7185

7286
describe("qa-channel plugin", () => {
7387
it("roundtrips inbound DM traffic through the qa bus", { timeout: 20_000 }, async () => {
88+
installQaChannelTestRegistry();
7489
const state = createQaBusState();
7590
const bus = await startQaBusServer({ state });
7691
setQaChannelRuntime(createMockQaRuntime());
@@ -120,6 +135,7 @@ describe("qa-channel plugin", () => {
120135
});
121136

122137
it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => {
138+
installQaChannelTestRegistry();
123139
const state = createQaBusState();
124140
const bus = await startQaBusServer({ state });
125141
let dispatchedCtx: Record<string, unknown> | null = null;
@@ -200,6 +216,7 @@ describe("qa-channel plugin", () => {
200216
});
201217

202218
it("exposes thread and message actions against the qa bus", async () => {
219+
installQaChannelTestRegistry();
203220
const state = createQaBusState();
204221
const bus = await startQaBusServer({ state });
205222

@@ -306,6 +323,7 @@ describe("qa-channel plugin", () => {
306323
});
307324

308325
it("routes the advertised send action to the qa bus", async () => {
326+
installQaChannelTestRegistry();
309327
const state = createQaBusState();
310328
const bus = await startQaBusServer({ state });
311329

extensions/qa-lab/src/bus-server.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
22
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3+
import { normalizeAccountId } from "./bus-queries.js";
34
import type { QaBusState } from "./bus-state.js";
45
import type {
56
QaBusCreateThreadInput,
@@ -134,16 +135,17 @@ export async function handleQaBusRequest(params: {
134135
case "/v1/poll": {
135136
const input = body as unknown as QaBusPollInput;
136137
const timeoutMs = Math.max(0, Math.min(input.timeoutMs ?? 0, 30_000));
138+
const accountId = normalizeAccountId(input.accountId);
137139
const initial = params.state.poll(input);
138140
if (initial.events.length > 0 || timeoutMs === 0) {
139141
writeJson(params.res, 200, initial);
140142
return true;
141143
}
142144
try {
143-
await params.state.waitFor({
144-
kind: "event-kind",
145-
eventKind: "inbound-message",
146-
timeoutMs,
145+
await params.state.waitForCursorAdvance(input.cursor ?? 0, timeoutMs, (snapshot) => {
146+
return snapshot.events.some(
147+
(event) => event.accountId === accountId && event.cursor > (input.cursor ?? 0),
148+
);
147149
});
148150
} catch {
149151
// timeout ok for long-poll

extensions/qa-lab/src/bus-state.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,50 @@ describe("qa-bus state", () => {
9292
).rejects.toThrow("qa-bus wait timeout");
9393
});
9494

95+
it("keeps account-scoped cursor waits blocked on unrelated account traffic", async () => {
96+
const state = createQaBusState();
97+
const pending = state.waitForCursorAdvance(0, 500, (snapshot) => {
98+
return snapshot.events.some((event) => event.accountId === "acct-a" && event.cursor > 0);
99+
});
100+
101+
state.addInboundMessage({
102+
accountId: "acct-b",
103+
conversation: { id: "other", kind: "direct" },
104+
senderId: "acct-b-user",
105+
text: "unrelated",
106+
});
107+
108+
const beforeMatch = await Promise.race([
109+
pending.then(() => "resolved"),
110+
new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 20)),
111+
]);
112+
expect(beforeMatch).toBe("still-waiting");
113+
114+
state.addInboundMessage({
115+
accountId: "acct-a",
116+
conversation: { id: "target", kind: "direct" },
117+
senderId: "acct-a-user",
118+
text: "matched",
119+
});
120+
121+
await expect(pending).resolves.toBeUndefined();
122+
});
123+
124+
it("wakes default-account cursor waits when accountId is omitted", async () => {
125+
const state = createQaBusState();
126+
const pending = state.waitForCursorAdvance(0, 500, (snapshot) => {
127+
return snapshot.events.some((event) => event.accountId === "default" && event.cursor > 0);
128+
});
129+
130+
state.addInboundMessage({
131+
conversation: { id: "target", kind: "direct" },
132+
senderId: "default-user",
133+
text: "matched",
134+
});
135+
136+
await expect(pending).resolves.toBeUndefined();
137+
});
138+
95139
it("preserves inline attachments and lets search match attachment metadata", () => {
96140
const state = createQaBusState();
97141

extensions/qa-lab/src/bus-state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
QaBusReadMessageInput,
2424
QaBusReactToMessageInput,
2525
QaBusSearchMessagesInput,
26+
QaBusStateSnapshot,
2627
QaBusThread,
2728
QaBusWaitForInput,
2829
} from "./runtime-api.js";
@@ -282,6 +283,13 @@ export function createQaBusState() {
282283
async waitFor(input: QaBusWaitForInput) {
283284
return await waiters.waitFor(input);
284285
},
286+
async waitForCursorAdvance(
287+
afterCursor: number,
288+
timeoutMs: number,
289+
shouldResolve?: (snapshot: QaBusStateSnapshot) => boolean,
290+
) {
291+
return await waiters.waitForCursorAdvance(afterCursor, timeoutMs, shouldResolve);
292+
},
285293
};
286294
}
287295

0 commit comments

Comments
 (0)