返回 CodeWhale
lib.test.mjs
根目录 / integrations / bridge-core / test / lib.test.mjs
1 import test from "node:test";
2 import assert from "node:assert/strict";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import path from "node:path";
6
7 import {
8 activeTurnBlock,
9 commandAction,
10 createRuntimeClient,
11 envFirst,
12 parseBool,
13 parseCommand,
14 parseEnvText,
15 parseList,
16 parseTextContent,
17 preservedChatStateFields,
18 readJsonSafe,
19 readSse,
20 splitMessage,
21 stripGroupPrefix,
22 ThreadStore
23 } from "../src/lib.mjs";
24
25 test("env and primitive parsers handle bridge env conventions", () => {
26 assert.equal(envFirst({ A: "", B: " value " }, "A", "B"), "value");
27 assert.deepEqual(parseList(" a, b ,, "), ["a", "b"]);
28 assert.equal(parseBool("yes"), true);
29 assert.equal(parseBool("0", true), false);
30 assert.deepEqual(parseEnvText("export A='one'\nB=\"two\"\n# nope"), { A: "one", B: "two" });
31 assert.deepEqual(parseEnvText("A='\nB=\"\nEMPTY=\"\""), { A: "'", B: '"', EMPTY: "" });
32 });
33
34 test("parseTextContent supports plain text and JSON text/content wrappers", () => {
35 assert.equal(parseTextContent("hello"), "hello");
36 assert.equal(parseTextContent(JSON.stringify({ text: "hello" })), "hello");
37 assert.equal(parseTextContent(JSON.stringify({ content: "hello" })), "hello");
38 });
39
40 test("stripGroupPrefix supports direct chat types and prefixed group text", () => {
41 assert.deepEqual(
42 stripGroupPrefix("inspect", {
43 chatType: "private",
44 requirePrefix: true,
45 prefix: "/cw",
46 directChatTypes: ["private"]
47 }),
48 { accepted: true, text: "inspect" }
49 );
50 assert.deepEqual(
51 stripGroupPrefix("/cw inspect", {
52 chatType: "group",
53 requirePrefix: true,
54 prefix: "/cw",
55 directChatTypes: ["private"]
56 }),
57 { accepted: true, text: "inspect" }
58 );
59 });
60
61 test("commands map common actions while menu/start stay opt in", () => {
62 assert.deepEqual(parseCommand("/allow@CodeWhaleBot ap_1 remember", { stripBotMention: true }), {
63 name: "allow",
64 args: "ap_1 remember"
65 });
66 assert.deepEqual(parseCommand("/allow@CodeWhaleBot ap_1 remember"), {
67 name: "allow@codewhalebot",
68 args: "ap_1 remember"
69 });
70 assert.deepEqual(commandAction(parseCommand("/status")), { kind: "status" });
71 assert.deepEqual(commandAction(parseCommand("/menu")), { kind: "prompt", prompt: "/menu" });
72 assert.deepEqual(commandAction(parseCommand("/menu"), { allowMenu: true }), { kind: "menu" });
73 assert.deepEqual(commandAction(parseCommand("/start"), { allowStart: true }), { kind: "help" });
74 });
75
76 test("state/message/runtime helpers preserve bridge behavior", () => {
77 assert.deepEqual(
78 preservedChatStateFields({ model: "m", replyToMessageId: "r", ignored: true }, [
79 "model",
80 "replyToMessageId"
81 ]),
82 { model: "m", replyToMessageId: "r" }
83 );
84 assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]);
85 assert.deepEqual(splitMessage("alpha beta gamma", 12), ["alpha beta ", "gamma"]);
86 const fenced = splitMessage("```js\nconst first = 1;\nconst second = 2;\n```\nDone", 24);
87 assert.ok(fenced.length > 1);
88 assert.equal(fenced[0].endsWith("\n```"), true);
89 assert.equal(fenced[1].startsWith("```js\n"), true);
90 assert.equal(fenced.at(-1).includes("Done"), true);
91 for (const chunk of fenced) {
92 assert.ok(Array.from(chunk).length <= 24);
93 assert.equal((chunk.match(/```/g) || []).length % 2, 0);
94 }
95 assert.deepEqual(activeTurnBlock({ turns: [{ id: "t1", status: "queued" }] }), {
96 turnId: "t1",
97 message: "Thread already has active turn t1. Wait for it to finish or send /interrupt."
98 });
99 assert.deepEqual(activeTurnBlock({ turns: [{ status: "in_progress" }] }, null), {
100 turnId: "",
101 message: "Thread already has active turn (unknown). Wait for it to finish or send /interrupt."
102 });
103 });
104
105 test("ThreadStore supports chat state, message dedupe, and action tokens", async () => {
106 const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-"));
107 try {
108 const statePath = path.join(dir, "thread-map.json");
109 const store = await ThreadStore.open(statePath, {
110 messageLimit: 2,
111 actions: true,
112 actionLimit: 2
113 });
114
115 await store.setChat("chat-a", { threadId: "thread-a" });
116 assert.equal((await store.getChat("chat-a")).threadId, "thread-a");
117
118 assert.equal(await store.recordMessage("m1"), false);
119 assert.equal(await store.recordMessage("m1"), true);
120 assert.equal(await store.recordMessage("m2"), false);
121 assert.equal(await store.recordMessage("m3"), false);
122 assert.deepEqual(store.data.messages, ["m2", "m3"]);
123
124 const token = await store.putAction({ kind: "resume", threadId: "thread-a" });
125 assert.equal((await store.getAction(token)).kind, "resume");
126 assert.equal((await store.takeAction(token)).threadId, "thread-a");
127 assert.equal(await store.getAction(token), null);
128
129 const saved = await ThreadStore.open(statePath, { messageLimit: 2, actions: true });
130 assert.equal((await saved.getChat("chat-a")).threadId, "thread-a");
131 assert.deepEqual(saved.data.messages, ["m2", "m3"]);
132 } finally {
133 await rm(dir, { recursive: true, force: true });
134 }
135 });
136
137 test("readJsonSafe tolerates empty and non-JSON bodies", async () => {
138 assert.deepEqual(await readJsonSafe({ text: async () => "" }), {});
139 assert.deepEqual(await readJsonSafe({ text: async () => '{"ok":true}' }), { ok: true });
140 assert.equal(await readJsonSafe({ text: async () => "plain text" }), "plain text");
141 });
142
143 test("readSse reassembles events split across chunks and strips CR", async () => {
144 const response = {
145 body: (async function* () {
146 yield Buffer.from('event: item.delta\ndata: {"seq":1}\n\nevent:');
147 yield Buffer.from(' turn.completed\r\ndata: {"seq":2}\n\n');
148 })()
149 };
150 const events = [];
151 for await (const event of readSse(response)) events.push(event);
152 assert.deepEqual(events, [
153 { event: "item.delta", data: '{"seq":1}' },
154 { event: "turn.completed", data: '{"seq":2}' }
155 ]);
156 });
157
158 test("createRuntimeClient sends bearer auth and surfaces runtime errors", async () => {
159 const calls = [];
160 const originalFetch = globalThis.fetch;
161 globalThis.fetch = async (url, options) => {
162 calls.push({ url: String(url), options });
163 if (String(url).endsWith("/fail")) {
164 return {
165 ok: false,
166 status: 503,
167 text: async () => JSON.stringify({ error: { message: "down" } })
168 };
169 }
170 return { ok: true, status: 200, text: async () => JSON.stringify({ ok: true }) };
171 };
172 try {
173 const { runtimeJson, authHeaders } = createRuntimeClient({
174 runtimeUrl: "http://127.0.0.1:7878",
175 runtimeToken: "token-1"
176 });
177 assert.deepEqual(authHeaders(), { authorization: "Bearer token-1" });
178
179 assert.deepEqual(await runtimeJson("/v1/threads", { method: "POST", body: { a: 1 } }), {
180 ok: true
181 });
182 assert.equal(calls[0].url, "http://127.0.0.1:7878/v1/threads");
183 assert.equal(calls[0].options.method, "POST");
184 assert.equal(calls[0].options.headers.authorization, "Bearer token-1");
185 assert.equal(calls[0].options.headers["content-type"], "application/json");
186 assert.equal(calls[0].options.body, JSON.stringify({ a: 1 }));
187
188 await runtimeJson("/health", { auth: false });
189 assert.equal(calls[1].options.method, "GET");
190 assert.deepEqual(calls[1].options.headers, {});
191
192 await assert.rejects(() => runtimeJson("/fail"), /Runtime API request failed \(503\): down/);
193 } finally {
194 globalThis.fetch = originalFetch;
195 }
196 });
197
198 test("ThreadStore batches rapid saves into coalesced durable writes", async () => {
199 const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-"));
200 try {
201 const statePath = path.join(dir, "thread-map.json");
202 const store = await ThreadStore.open(statePath);
203 let writes = 0;
204 const originalWrite = store.writeSnapshot.bind(store);
205 store.writeSnapshot = async () => {
206 writes += 1;
207 return originalWrite();
208 };
209
210 await Promise.all(
211 Array.from({ length: 25 }, (_, index) =>
212 store.setChat(`chat-${index}`, { threadId: `thread-${index}` })
213 )
214 );
215 assert.ok(writes <= 2, `expected coalesced writes, saw ${writes}`);
216
217 const saved = await ThreadStore.open(statePath);
218 assert.equal((await saved.getChat("chat-0")).threadId, "thread-0");
219 assert.equal((await saved.getChat("chat-24")).threadId, "thread-24");
220 } finally {
221 await rm(dir, { recursive: true, force: true });
222 }
223 });
224
225 test("ThreadStore persists numeric cursors", async () => {
226 const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-"));
227 try {
228 const statePath = path.join(dir, "thread-map.json");
229 const store = await ThreadStore.open(statePath);
230
231 assert.equal(store.getCursor("telegram.update_offset", 7), 7);
232 assert.equal(await store.setCursor("telegram.update_offset", 42), 42);
233 assert.equal(store.getCursor("telegram.update_offset"), 42);
234
235 const saved = await ThreadStore.open(statePath);
236 assert.equal(saved.getCursor("telegram.update_offset"), 42);
237 } finally {
238 await rm(dir, { recursive: true, force: true });
239 }
240 });
241
241 lines Plain Text