im_wower
·
2026-04-01
controller.test.cjs
1const assert = require("node:assert/strict");
2const fs = require("node:fs");
3const path = require("node:path");
4const test = require("node:test");
5const vm = require("node:vm");
6
7const CONTROLLER_SOURCE = fs.readFileSync(
8 path.join(__dirname, "controller.js"),
9 "utf8"
10);
11
12class FakeWebSocket {
13 static CONNECTING = 0;
14 static OPEN = 1;
15 static CLOSING = 2;
16 static CLOSED = 3;
17 static instances = [];
18
19 constructor(url) {
20 this.url = url;
21 this.readyState = FakeWebSocket.CONNECTING;
22 this.sent = [];
23 this.onclose = null;
24 this.onerror = null;
25 this.onmessage = null;
26 this.onopen = null;
27 FakeWebSocket.instances.push(this);
28 }
29
30 send(payload) {
31 this.sent.push(JSON.parse(payload));
32 }
33
34 close(code = 1000, reason = "") {
35 this.readyState = FakeWebSocket.CLOSED;
36 if (typeof this.onclose === "function") {
37 this.onclose({ code, reason });
38 }
39 }
40}
41
42function createControllerHarness(options = {}) {
43 FakeWebSocket.instances = [];
44
45 const windowListeners = new Map();
46 const window = {
47 addEventListener(type, listener) {
48 if (!windowListeners.has(type)) {
49 windowListeners.set(type, new Set());
50 }
51 windowListeners.get(type).add(listener);
52 },
53 dispatchEvent() {
54 return true;
55 },
56 location: {
57 hash: "",
58 href: "moz-extension://baa-firefox/controller.html",
59 hostname: "baa-firefox",
60 origin: "moz-extension://baa-firefox",
61 pathname: "/controller.html"
62 },
63 removeEventListener(type, listener) {
64 windowListeners.get(type)?.delete(listener);
65 }
66 };
67 window.location.reload = () => {};
68
69 const context = vm.createContext({
70 AbortController,
71 ArrayBuffer,
72 Blob,
73 FormData,
74 Headers,
75 Request,
76 Response,
77 URL,
78 URLSearchParams,
79 BAAFinalMessage: options.finalMessageHelpers || null,
80 WebSocket: FakeWebSocket,
81 crypto: globalThis.crypto,
82 browser: {
83 runtime: {
84 onMessage: {
85 addListener() {},
86 removeListener() {}
87 },
88 sendMessage() {
89 return Promise.resolve();
90 }
91 },
92 storage: {
93 local: {
94 get() {
95 return Promise.resolve({});
96 },
97 remove() {
98 return Promise.resolve();
99 },
100 set() {
101 return Promise.resolve();
102 }
103 }
104 },
105 tabs: {
106 create() {
107 return Promise.resolve({ id: 1 });
108 },
109 getCurrent() {
110 return Promise.resolve({ id: 1 });
111 },
112 query() {
113 return Promise.resolve([]);
114 },
115 reload() {
116 return Promise.resolve();
117 },
118 remove() {
119 return Promise.resolve();
120 },
121 update() {
122 return Promise.resolve();
123 }
124 }
125 },
126 clearTimeout,
127 console: {
128 error() {},
129 log() {},
130 warn() {}
131 },
132 document: {
133 getElementById() {
134 return null;
135 }
136 },
137 fetch() {
138 if (typeof options.fetchImpl === "function") {
139 return options.fetchImpl(...arguments);
140 }
141 return Promise.resolve(new Response("{}", { status: 200 }));
142 },
143 location: window.location,
144 setTimeout,
145 window,
146 __BAA_CONTROLLER_TEST_API__: {},
147 __BAA_SKIP_CONTROLLER_INIT__: true
148 });
149
150 vm.runInContext(CONTROLLER_SOURCE, context, {
151 filename: "controller.js"
152 });
153
154 return {
155 api: context.__BAA_CONTROLLER_TEST_API__,
156 context
157 };
158}
159
160test("controller creates final message relay observers for every supported platform, including Claude", () => {
161 const createRelayStateCalls = [];
162 const { api } = createControllerHarness({
163 finalMessageHelpers: {
164 createRelayState(platform) {
165 createRelayStateCalls.push(platform);
166 return { platform };
167 }
168 }
169 });
170
171 assert.deepEqual(createRelayStateCalls, ["claude", "chatgpt", "gemini"]);
172 assert.deepEqual(api.state.finalMessageRelayObservers.claude, { platform: "claude" });
173 assert.deepEqual(api.state.finalMessageRelayObservers.chatgpt, { platform: "chatgpt" });
174 assert.deepEqual(api.state.finalMessageRelayObservers.gemini, { platform: "gemini" });
175});
176
177function parseGeminiRequestTuple(reqBody) {
178 const params = new URLSearchParams(reqBody);
179 const outer = JSON.parse(params.get("f.req"));
180 return JSON.parse(outer[1]);
181}
182
183test("controller flushes buffered plugin diagnostic logs after the WS opens", () => {
184 const { api } = createControllerHarness();
185
186 api.state.clientId = "firefox-test";
187 api.state.wsUrl = "ws://127.0.0.1:4317/ws/firefox";
188
189 api.handlePageDiagnosticLog({
190 event: "page_bridge_ready",
191 platform: "chatgpt",
192 source: "content-script",
193 url: "https://chatgpt.com/c/conv-1"
194 }, {});
195 api.handlePageDiagnosticLog({
196 event: "interceptor_active",
197 platform: "chatgpt",
198 source: "page-interceptor",
199 url: "https://chatgpt.com/c/conv-1"
200 }, {});
201
202 assert.equal(api.state.pendingPluginDiagnosticLogs.length, 2);
203
204 api.connectWs({
205 silentWhenDisabled: true
206 });
207
208 const socket = FakeWebSocket.instances.at(-1);
209 assert.ok(socket);
210 assert.equal(api.state.pendingPluginDiagnosticLogs.length >= 3, true);
211
212 socket.readyState = FakeWebSocket.OPEN;
213 socket.onopen();
214
215 const pluginLogTexts = socket.sent
216 .filter((message) => message.type === "plugin_diagnostic_log")
217 .map((message) => message.text);
218 const pageBridgeIndex = pluginLogTexts.findIndex((text) => text.includes("page_bridge_ready"));
219 const interceptorIndex = pluginLogTexts.findIndex((text) => text.includes("interceptor_active"));
220 const connectedIndex = pluginLogTexts.findIndex((text) => text === "本地 WS 已连接");
221
222 assert.equal(socket.sent[0]?.type, "hello");
223 assert.equal(pageBridgeIndex >= 0, true);
224 assert.equal(interceptorIndex >= 0, true);
225 assert.equal(connectedIndex >= 0, true);
226 assert.equal(pageBridgeIndex < connectedIndex, true);
227 assert.equal(interceptorIndex < connectedIndex, true);
228 assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
229});
230
231test("controller bounds buffered plugin diagnostic logs and flushes the newest entries in order", () => {
232 const { api } = createControllerHarness();
233
234 api.state.clientId = "firefox-buffer";
235
236 for (let index = 1; index <= 60; index += 1) {
237 api.sendPluginDiagnosticLog("info", `buffered-log-${index}`);
238 }
239
240 assert.equal(api.state.pendingPluginDiagnosticLogs.length, 50);
241 assert.equal(api.state.pendingPluginDiagnosticLogs[0]?.text, "buffered-log-11");
242 assert.equal(api.state.pendingPluginDiagnosticLogs[49]?.text, "buffered-log-60");
243
244 const sent = [];
245 api.state.ws = {
246 readyState: FakeWebSocket.OPEN,
247 send(payload) {
248 sent.push(JSON.parse(payload));
249 }
250 };
251
252 const flushedCount = api.flushBufferedPluginDiagnosticLogs();
253
254 assert.equal(flushedCount, 50);
255 assert.equal(sent.length, 50);
256 assert.equal(sent[0]?.text, "buffered-log-11");
257 assert.equal(sent[49]?.text, "buffered-log-60");
258 assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
259});
260
261test("controller routes page control actions to conversation automation control and updates page state", async () => {
262 const requests = [];
263 const { api } = createControllerHarness({
264 fetchImpl(url, options = {}) {
265 requests.push({
266 body: options.body ? JSON.parse(options.body) : null,
267 method: options.method || "GET",
268 url
269 });
270 return Promise.resolve(new Response(JSON.stringify({
271 ok: true,
272 data: {
273 local_conversation_id: "lc-chatgpt-1",
274 platform: "chatgpt",
275 automation_status: "manual",
276 last_non_paused_automation_status: "manual",
277 updated_at: 1_710_000_100_000,
278 active_link: {
279 remote_conversation_id: "conv-automation-control",
280 page_url: "https://chatgpt.com/c/conv-automation-control",
281 page_title: "ChatGPT Automation"
282 }
283 }
284 }), {
285 status: 200,
286 headers: {
287 "content-type": "application/json"
288 }
289 }));
290 }
291 });
292
293 api.state.controlBaseUrl = "http://127.0.0.1:4317";
294
295 const sender = {
296 tab: {
297 id: 42,
298 title: "ChatGPT Automation",
299 url: "https://chatgpt.com/c/conv-automation-control"
300 }
301 };
302
303 const result = await api.runPageControlAction("manual", sender, {
304 source: "page_overlay"
305 });
306
307 assert.equal(requests.length, 1);
308 assert.equal(requests[0].method, "POST");
309 assert.equal(requests[0].url, "http://127.0.0.1:4317/v1/internal/automation/conversations/control");
310 assert.deepEqual(requests[0].body, {
311 action: "mode",
312 scope: "current",
313 mode: "manual",
314 platform: "chatgpt",
315 source_conversation_id: "conv-automation-control"
316 });
317 assert.equal(result.page.automationStatus, "manual");
318 assert.equal(result.page.localConversationId, "lc-chatgpt-1");
319 assert.equal(result.page.paused, true);
320});
321
322test("controller applies websocket automation snapshots to control and page state", () => {
323 const { api } = createControllerHarness();
324 const sender = {
325 tab: {
326 id: 7,
327 title: "ChatGPT Overlay",
328 url: "https://chatgpt.com/c/conv-ws-sync"
329 }
330 };
331 const context = api.getSenderContext(sender, "chatgpt");
332
333 api.syncPageControlFromContext(context, {}, {
334 persist: false,
335 render: false
336 });
337
338 api.handleWsStateSnapshot({
339 reason: "poll",
340 snapshot: {
341 server: {
342 identity: "mini-main@mini(primary)",
343 local_api_base: "http://127.0.0.1:4317",
344 ws_url: "ws://127.0.0.1:4317/ws/firefox"
345 },
346 system: {
347 mode: "paused",
348 updated_at: 1_710_000_000_000,
349 leader: {
350 controller_id: "mini-main"
351 },
352 queue: {
353 active_runs: 0,
354 queued_tasks: 0
355 }
356 },
357 browser: {
358 client_count: 1,
359 automation_conversations: [
360 {
361 platform: "chatgpt",
362 remote_conversation_id: "conv-ws-sync",
363 local_conversation_id: "lc-ws-sync",
364 automation_status: "paused",
365 last_non_paused_automation_status: "auto",
366 pause_reason: "repeated_message",
367 updated_at: 1_710_000_000_500,
368 active_link: {
369 page_url: "https://chatgpt.com/c/conv-ws-sync",
370 page_title: "ChatGPT Overlay"
371 }
372 }
373 ]
374 }
375 }
376 });
377
378 assert.equal(api.state.controlState.mode, "paused");
379 assert.equal(api.state.controlState.controlConnection, "connected");
380 const pageState = api.state.pageControls["chatgpt:7"];
381 assert.equal(pageState.localConversationId, "lc-ws-sync");
382 assert.equal(pageState.automationStatus, "paused");
383 assert.equal(pageState.pauseReason, "repeated_message");
384 assert.equal(pageState.paused, true);
385});
386
387test("controller restores persisted ChatGPT send templates and reuses them for proxy delivery", () => {
388 const nowMs = Date.now();
389 const { api } = createControllerHarness();
390
391 api.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
392 api.rememberChatgptSendTemplate({
393 conversationId: "conv-persisted",
394 senderUrl: "https://chatgpt.com/c/conv-persisted"
395 }, JSON.stringify({
396 action: "next",
397 conversation_id: "conv-persisted",
398 messages: [
399 {
400 author: {
401 role: "user"
402 },
403 content: {
404 content_type: "text",
405 parts: ["hello"]
406 },
407 id: "msg-template"
408 }
409 ],
410 model: "gpt-4o",
411 websocket_request_id: "request-template"
412 }));
413
414 const savedTemplates = api.serializeChatgptSendTemplates(nowMs);
415 const { api: restoredApi } = createControllerHarness();
416
417 restoredApi.state.chatgptSendTemplates = restoredApi.loadChatgptSendTemplates(savedTemplates, nowMs);
418 restoredApi.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
419 restoredApi.state.trackedTabs.chatgpt = 17;
420 restoredApi.state.lastHeaders.chatgpt = {
421 authorization: "Bearer test-token",
422 cookie: "__Secure-next-auth.session-token=test-session",
423 "x-openai-assistant-app-id": "chatgpt"
424 };
425 restoredApi.state.credentialCapturedAt.chatgpt = nowMs;
426 restoredApi.state.lastCredentialAt.chatgpt = nowMs;
427 restoredApi.state.lastCredentialTabId.chatgpt = 17;
428 restoredApi.state.lastCredentialUrl.chatgpt = "https://chatgpt.com/backend-api/conversation";
429
430 const template = restoredApi.getChatgptSendTemplate("conv-persisted");
431 const request = restoredApi.buildChatgptDeliveryRequest({
432 conversationId: "conv-persisted",
433 messageText: "follow-up from persisted template",
434 sourceAssistantMessageId: "assistant-msg-1"
435 });
436
437 assert.ok(template);
438 assert.equal(template.credentialFingerprint, "fp-chatgpt-1");
439 assert.equal(request.method, "POST");
440 assert.equal(request.path, "/backend-api/conversation");
441 assert.equal(request.body.conversation_id, "conv-persisted");
442 assert.equal(request.body.model, "gpt-4o");
443 assert.equal(request.body.parent_message_id, "assistant-msg-1");
444 assert.equal(request.body.messages[0].author.role, "user");
445 assert.equal(request.body.messages[0].content.parts[0], "follow-up from persisted template");
446 assert.equal(request.headers.accept, "text/event-stream");
447});
448
449test("controller invalidates persisted ChatGPT templates when the credential fingerprint changes", () => {
450 const nowMs = Date.now();
451 const { api } = createControllerHarness();
452
453 api.state.chatgptSendTemplates = api.loadChatgptSendTemplates({
454 "conv-fingerprint-mismatch": {
455 conversationId: "conv-fingerprint-mismatch",
456 credentialFingerprint: "fp-old",
457 model: "gpt-4o",
458 pageUrl: "https://chatgpt.com/c/conv-fingerprint-mismatch",
459 reqBody: JSON.stringify({
460 action: "next",
461 conversation_id: "conv-fingerprint-mismatch",
462 messages: [
463 {
464 author: {
465 role: "user"
466 },
467 content: {
468 content_type: "text",
469 parts: ["hello"]
470 },
471 id: "msg-template"
472 }
473 ]
474 }),
475 updatedAt: nowMs
476 }
477 }, nowMs);
478 api.state.credentialFingerprint.chatgpt = "fp-new";
479
480 const template = api.getChatgptSendTemplate("conv-fingerprint-mismatch");
481
482 assert.equal(template, null);
483 assert.equal(Object.keys(api.serializeChatgptSendTemplates(nowMs)).length, 0);
484});
485
486test("controller restores persisted Gemini send templates and reuses the matching conversation template for proxy delivery", () => {
487 const nowMs = Date.now();
488 const templateUrl = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=420000&rt=c";
489 const requestHeaders = {
490 "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
491 cookie: "__Secure-1PSID=test-session",
492 "x-goog-authuser": "0",
493 "x-same-domain": "1"
494 };
495 const templateBody = new URLSearchParams({
496 at: "xsrf-token-1",
497 "f.req": JSON.stringify([
498 null,
499 JSON.stringify([
500 ["hello from gemini"],
501 null,
502 ["conv-gemini-persisted", "resp-1", "choice-1"]
503 ])
504 ])
505 }).toString();
506 const { api } = createControllerHarness();
507
508 api.state.credentialFingerprint.gemini = "fp-gemini-1";
509 api.rememberGeminiSendTemplate({
510 conversationId: "conv-gemini-persisted",
511 isShellPage: false,
512 senderUrl: "https://gemini.google.com/app/conv-gemini-persisted"
513 }, templateUrl, templateBody, requestHeaders);
514
515 const savedTemplates = api.serializeGeminiSendTemplates(nowMs);
516 const { api: restoredApi } = createControllerHarness();
517
518 restoredApi.state.geminiSendTemplates = restoredApi.loadGeminiSendTemplates(savedTemplates, null, nowMs);
519 restoredApi.state.credentialFingerprint.gemini = "fp-gemini-1";
520 restoredApi.state.trackedTabs.gemini = 29;
521 restoredApi.state.lastHeaders.gemini = requestHeaders;
522 restoredApi.state.credentialCapturedAt.gemini = nowMs;
523 restoredApi.state.lastCredentialAt.gemini = nowMs;
524 restoredApi.state.lastCredentialTabId.gemini = 29;
525 restoredApi.state.lastCredentialUrl.gemini = templateUrl;
526
527 const template = restoredApi.getGeminiSendTemplate({
528 conversationId: "conv-gemini-persisted"
529 });
530 const request = restoredApi.buildGeminiDeliveryRequest({
531 conversationId: "conv-gemini-persisted",
532 messageText: "follow-up from persisted Gemini template"
533 });
534 const requestTuple = parseGeminiRequestTuple(request.body);
535 const requestPath = new URL(request.path, "https://gemini.google.com/");
536
537 assert.ok(template);
538 assert.equal(template.match, "conversation");
539 assert.equal(template.credentialFingerprint, "fp-gemini-1");
540 assert.equal(request.method, "POST");
541 assert.equal(request.templateKey, "conv-gemini-persisted");
542 assert.equal(
543 requestPath.pathname,
544 "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
545 );
546 assert.equal(Number(requestPath.searchParams.get("_reqid")) > 420000, true);
547 assert.equal(requestTuple[0][0], "follow-up from persisted Gemini template");
548 assert.deepEqual(requestTuple[2], ["conv-gemini-persisted", "resp-1", "choice-1"]);
549 assert.equal(request.headers["content-type"], "application/x-www-form-urlencoded;charset=UTF-8");
550 assert.equal(request.headers["x-same-domain"], "1");
551});
552
553test("controller invalidates persisted Gemini templates when the credential fingerprint changes", () => {
554 const nowMs = Date.now();
555 const { api } = createControllerHarness();
556
557 api.state.geminiSendTemplates = api.loadGeminiSendTemplates({
558 "conv-gemini-fingerprint-mismatch": {
559 conversationId: "conv-gemini-fingerprint-mismatch",
560 credentialFingerprint: "fp-old",
561 headers: {
562 "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
563 "x-same-domain": "1"
564 },
565 pageUrl: "https://gemini.google.com/app/conv-gemini-fingerprint-mismatch",
566 reqBody: new URLSearchParams({
567 at: "xsrf-token-old",
568 "f.req": JSON.stringify([
569 null,
570 JSON.stringify([
571 ["hello"],
572 null,
573 ["conv-gemini-fingerprint-mismatch", "resp-old", "choice-old"]
574 ])
575 ])
576 }).toString(),
577 reqId: 520000,
578 shellPage: false,
579 updatedAt: nowMs,
580 url: "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=520000&rt=c"
581 }
582 }, null, nowMs);
583 api.state.credentialFingerprint.gemini = "fp-new";
584
585 const template = api.getGeminiSendTemplate({
586 conversationId: "conv-gemini-fingerprint-mismatch"
587 });
588
589 assert.equal(template, null);
590 assert.equal(Object.keys(api.serializeGeminiSendTemplates(nowMs)).length, 0);
591});