baa-conductor


baa-conductor / plugins / baa-firefox
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});