codex@macbookpro
·
2026-03-31
browser-control-e2e-smoke.test.mjs
1import assert from "node:assert/strict";
2import { execFileSync } from "node:child_process";
3import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4import { createRequire } from "node:module";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7import test from "node:test";
8import { fileURLToPath } from "node:url";
9import vm from "node:vm";
10
11const require = createRequire(import.meta.url);
12const repoRoot = new URL("../../", import.meta.url);
13const conductorTsconfigPath = new URL("../../apps/conductor-daemon/tsconfig.json", import.meta.url);
14const tscPath = new URL("../../node_modules/.bin/tsc", import.meta.url);
15
16execFileSync(fileURLToPath(tscPath), ["-p", fileURLToPath(conductorTsconfigPath)], {
17 cwd: fileURLToPath(repoRoot),
18 stdio: "ignore"
19});
20
21const { ConductorRuntime } = await import("../../apps/conductor-daemon/dist/index.js");
22
23const {
24 createDeliveryRuntime,
25 getPlatformAdapter,
26} = require("../../plugins/baa-firefox/delivery-adapters.js");
27const finalMessageHelpers = require("../../plugins/baa-firefox/final-message.js");
28const {
29 createRelayState,
30 observeNetwork,
31 observeSse,
32 rememberRelay
33} = finalMessageHelpers;
34const controllerSource = readFileSync(
35 new URL("../../plugins/baa-firefox/controller.js", import.meta.url),
36 "utf8"
37);
38const contentScriptSource = readFileSync(
39 new URL("../../plugins/baa-firefox/content-script.js", import.meta.url),
40 "utf8"
41);
42
43function createWebSocketMessageQueue(socket) {
44 const messages = [];
45 const waiters = [];
46
47 const onMessage = (event) => {
48 let payload = null;
49
50 try {
51 payload = JSON.parse(event.data);
52 } catch {
53 return;
54 }
55
56 const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(payload));
57
58 if (waiterIndex >= 0) {
59 const [waiter] = waiters.splice(waiterIndex, 1);
60
61 if (waiter) {
62 clearTimeout(waiter.timer);
63 waiter.resolve(payload);
64 }
65
66 return;
67 }
68
69 messages.push(payload);
70 };
71
72 const onClose = () => {
73 while (waiters.length > 0) {
74 const waiter = waiters.shift();
75
76 if (waiter) {
77 clearTimeout(waiter.timer);
78 waiter.reject(new Error("websocket closed before the expected message arrived"));
79 }
80 }
81 };
82
83 socket.addEventListener("message", onMessage);
84 socket.addEventListener("close", onClose);
85
86 return {
87 async next(predicate, timeoutMs = 5_000) {
88 const existingIndex = messages.findIndex((message) => predicate(message));
89
90 if (existingIndex >= 0) {
91 const [message] = messages.splice(existingIndex, 1);
92 return message;
93 }
94
95 return await new Promise((resolve, reject) => {
96 const timer = setTimeout(() => {
97 const waiterIndex = waiters.findIndex((waiter) => waiter.timer === timer);
98
99 if (waiterIndex >= 0) {
100 waiters.splice(waiterIndex, 1);
101 }
102
103 reject(new Error("timed out waiting for websocket message"));
104 }, timeoutMs);
105
106 waiters.push({
107 predicate,
108 reject,
109 resolve,
110 timer
111 });
112 });
113 },
114 stop() {
115 socket.removeEventListener("message", onMessage);
116 socket.removeEventListener("close", onClose);
117 onClose();
118 }
119 };
120}
121
122async function waitForWebSocketOpen(socket) {
123 if (socket.readyState === WebSocket.OPEN) {
124 return;
125 }
126
127 await new Promise((resolve, reject) => {
128 const onOpen = () => {
129 socket.removeEventListener("error", onError);
130 resolve();
131 };
132 const onError = () => {
133 socket.removeEventListener("open", onOpen);
134 reject(new Error("websocket failed to open"));
135 };
136
137 socket.addEventListener("open", onOpen, {
138 once: true
139 });
140 socket.addEventListener("error", onError, {
141 once: true
142 });
143 });
144}
145
146async function waitForWebSocketClose(socket) {
147 if (socket.readyState === WebSocket.CLOSED) {
148 return;
149 }
150
151 await new Promise((resolve) => {
152 socket.addEventListener("close", () => resolve(), {
153 once: true
154 });
155 });
156}
157
158async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
159 const startedAt = Date.now();
160 let lastError = null;
161
162 while (Date.now() - startedAt < timeoutMs) {
163 try {
164 return await assertion();
165 } catch (error) {
166 lastError = error;
167 }
168
169 await new Promise((resolve) => setTimeout(resolve, intervalMs));
170 }
171
172 throw lastError ?? new Error("timed out waiting for condition");
173}
174
175async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
176 await assert.rejects(
177 () => queue.next(predicate, timeoutMs),
178 /timed out waiting for websocket message/u
179 );
180}
181
182async function connectFirefoxBridgeClient(wsUrl, clientId) {
183 const socket = new WebSocket(wsUrl);
184 const queue = createWebSocketMessageQueue(socket);
185
186 await waitForWebSocketOpen(socket);
187 socket.send(
188 JSON.stringify({
189 type: "hello",
190 clientId,
191 nodeType: "browser",
192 nodeCategory: "proxy",
193 nodePlatform: "firefox"
194 })
195 );
196
197 const helloAck = await queue.next(
198 (message) => message.type === "hello_ack" && message.clientId === clientId
199 );
200 const initialSnapshot = await queue.next(
201 (message) => message.type === "state_snapshot" && message.reason === "hello"
202 );
203 const credentialRequest = await queue.next(
204 (message) => message.type === "request_credentials" && message.reason === "hello"
205 );
206
207 return {
208 credentialRequest,
209 helloAck,
210 initialSnapshot,
211 queue,
212 socket
213 };
214}
215
216async function fetchJson(url, init) {
217 const response = await fetch(url, init);
218 const text = await response.text();
219
220 return {
221 payload: text === "" ? null : JSON.parse(text),
222 response,
223 text
224 };
225}
226
227async function fetchText(url, init) {
228 const response = await fetch(url, init);
229 const text = await response.text();
230
231 return {
232 response,
233 text
234 };
235}
236
237function createControllerUiElement() {
238 return {
239 addEventListener() {},
240 className: "",
241 disabled: false,
242 textContent: ""
243 };
244}
245
246function wildcardPatternToRegExp(pattern) {
247 const escaped = String(pattern || "")
248 .replace(/[.+?^${}()|[\]\\]/gu, "\\$&")
249 .replace(/\*/gu, ".*");
250 return new RegExp(`^${escaped}$`, "u");
251}
252
253function matchesUrlPatterns(url, patterns = []) {
254 return patterns.some((pattern) => wildcardPatternToRegExp(pattern).test(url));
255}
256
257function createControllerHarness(options = {}) {
258 const executeScriptCalls = [];
259 const reloadedTabIds = [];
260 const tabMessages = [];
261 const tabs = new Map();
262
263 for (const tab of options.tabs || []) {
264 tabs.set(tab.id, {
265 active: false,
266 discarded: false,
267 hidden: false,
268 lastAccessed: 0,
269 status: "complete",
270 title: "",
271 windowId: 1,
272 ...tab
273 });
274 }
275
276 let nextTabId = Math.max(0, ...tabs.keys()) + 1;
277 const storage = options.storage || {};
278 const sentMessages = [];
279 const ws = options.ws || {
280 readyState: 1,
281 send(payload) {
282 sentMessages.push(JSON.parse(payload));
283 }
284 };
285 const browser = {
286 action: {
287 onClicked: {
288 addListener() {}
289 },
290 async setBadgeBackgroundColor() {},
291 async setBadgeText() {},
292 async setTitle() {}
293 },
294 runtime: {
295 async sendMessage() {
296 return { ok: true };
297 },
298 onMessage: {
299 addListener() {}
300 }
301 },
302 scripting: {
303 async executeScript(details) {
304 executeScriptCalls.push(JSON.parse(JSON.stringify(details)));
305 return [];
306 }
307 },
308 storage: {
309 local: {
310 async get(keys) {
311 if (Array.isArray(keys)) {
312 return Object.fromEntries(keys.map((key) => [key, storage[key]]));
313 }
314
315 if (typeof keys === "string") {
316 return {
317 [keys]: storage[keys]
318 };
319 }
320
321 return { ...storage };
322 },
323 async set(values) {
324 Object.assign(storage, values || {});
325 }
326 },
327 onChanged: {
328 addListener() {}
329 }
330 },
331 tabs: {
332 async create(info = {}) {
333 const tab = {
334 active: !!info.active,
335 discarded: false,
336 hidden: false,
337 id: nextTabId,
338 lastAccessed: Date.now(),
339 status: "complete",
340 title: "",
341 url: info.url || "",
342 windowId: 1
343 };
344 nextTabId += 1;
345 tabs.set(tab.id, tab);
346 return { ...tab };
347 },
348 async get(tabId) {
349 const tab = tabs.get(tabId);
350 if (!tab) {
351 throw new Error(`missing tab ${tabId}`);
352 }
353
354 return { ...tab };
355 },
356 async query(queryInfo = {}) {
357 const patterns = Array.isArray(queryInfo.url) ? queryInfo.url : [queryInfo.url].filter(Boolean);
358 const source = [...tabs.values()];
359 if (patterns.length === 0) {
360 return source.map((tab) => ({ ...tab }));
361 }
362
363 return source
364 .filter((tab) => matchesUrlPatterns(tab.url || "", patterns))
365 .map((tab) => ({ ...tab }));
366 },
367 async reload(tabId) {
368 reloadedTabIds.push(tabId);
369 const tab = tabs.get(tabId);
370 if (tab) {
371 tabs.set(tabId, {
372 ...tab,
373 status: "loading"
374 });
375 }
376 },
377 async remove(tabIds) {
378 for (const tabId of Array.isArray(tabIds) ? tabIds : [tabIds]) {
379 tabs.delete(tabId);
380 }
381 },
382 async sendMessage(tabId, payload) {
383 const clonedPayload = payload == null ? payload : JSON.parse(JSON.stringify(payload));
384 tabMessages.push({
385 payload: clonedPayload,
386 tabId
387 });
388
389 if (typeof options.onTabMessage === "function") {
390 return await options.onTabMessage(tabId, clonedPayload);
391 }
392
393 return { ok: true };
394 },
395 async update(tabId, patch = {}) {
396 const current = tabs.get(tabId);
397 if (!current) {
398 throw new Error(`missing tab ${tabId}`);
399 }
400
401 const next = {
402 ...current,
403 ...patch
404 };
405 tabs.set(tabId, next);
406 return { ...next };
407 },
408 onActivated: {
409 addListener() {}
410 },
411 onCreated: {
412 addListener() {}
413 },
414 onRemoved: {
415 addListener() {}
416 },
417 onUpdated: {
418 addListener() {}
419 }
420 },
421 webRequest: {
422 onBeforeSendHeaders: {
423 addListener() {}
424 },
425 onCompleted: {
426 addListener() {}
427 },
428 onErrorOccurred: {
429 addListener() {}
430 }
431 },
432 windows: {
433 async update() {}
434 }
435 };
436 const context = {
437 AbortController,
438 Blob,
439 BAAFinalMessage: options.finalMessageHelpers || null,
440 Headers,
441 FormData,
442 Request,
443 Response,
444 TextDecoder,
445 TextEncoder,
446 URL,
447 URLSearchParams,
448 WebSocket: {
449 OPEN: 1
450 },
451 browser,
452 clearInterval,
453 clearTimeout,
454 console,
455 crypto: globalThis.crypto,
456 __BAA_TEST_WS__: ws,
457 document: {
458 getElementById() {
459 return createControllerUiElement();
460 }
461 },
462 fetch: async () => new Response("{}", {
463 status: 200,
464 headers: {
465 "content-type": "application/json"
466 }
467 }),
468 globalThis: null,
469 location: {
470 href: "moz-extension://baa/controller.html",
471 reload() {}
472 },
473 performance: {
474 now() {
475 return 0;
476 }
477 },
478 setInterval,
479 setTimeout,
480 window: null,
481 __BAA_CONTROLLER_TEST_API__: {},
482 __BAA_SKIP_CONTROLLER_INIT__: true
483 };
484
485 context.window = context;
486 context.globalThis = context;
487 context.addEventListener = () => {};
488 context.removeEventListener = () => {};
489 vm.runInNewContext(controllerSource, context, {
490 filename: "controller.js"
491 });
492 vm.runInNewContext(`
493 __BAA_CONTROLLER_TEST_API__.state.ws = __BAA_TEST_WS__;
494 __BAA_CONTROLLER_TEST_API__.state.wsConnected = true;
495 `, context);
496
497 const hooks = context.__BAA_CONTROLLER_TEST_API__;
498
499 return {
500 executeScriptCalls,
501 hooks,
502 reloadedTabIds,
503 sentMessages,
504 tabMessages,
505 tabs
506 };
507}
508
509function createMockDomNode(tagName = "div") {
510 return {
511 children: [],
512 className: "",
513 dataset: {},
514 hidden: false,
515 id: "",
516 isConnected: false,
517 listeners: new Map(),
518 parentNode: null,
519 shadowRoot: null,
520 tagName: String(tagName || "div").toUpperCase(),
521 textContent: "",
522 appendChild(child) {
523 if (!child || typeof child !== "object") {
524 return child;
525 }
526
527 child.parentNode = this;
528 child.isConnected = true;
529 this.children.push(child);
530 return child;
531 },
532 attachShadow() {
533 const shadow = createMockDomNode("#shadow-root");
534 shadow.host = this;
535 shadow.isConnected = true;
536 this.shadowRoot = shadow;
537 return shadow;
538 },
539 addEventListener(type, listener) {
540 if (!this.listeners.has(type)) {
541 this.listeners.set(type, new Set());
542 }
543 this.listeners.get(type)?.add(listener);
544 },
545 remove() {
546 if (!this.parentNode) {
547 this.isConnected = false;
548 return;
549 }
550
551 this.parentNode.children = this.parentNode.children.filter((entry) => entry !== this);
552 this.parentNode = null;
553 this.isConnected = false;
554 }
555 };
556}
557
558function findDomNodesById(root, id, matches = []) {
559 if (!root || typeof root !== "object") {
560 return matches;
561 }
562
563 if (root.id === id) {
564 matches.push(root);
565 }
566
567 for (const child of root.children || []) {
568 findDomNodesById(child, id, matches);
569 }
570
571 if (root.shadowRoot) {
572 findDomNodesById(root.shadowRoot, id, matches);
573 }
574
575 return matches;
576}
577
578function createContentScriptHarness() {
579 const storage = {};
580 const runtimeMessageListeners = new Set();
581 const storageListeners = new Set();
582 const body = createMockDomNode("body");
583 const documentElement = createMockDomNode("html");
584 documentElement.appendChild(body);
585
586 const document = {
587 body,
588 documentElement,
589 readyState: "complete",
590 addEventListener() {},
591 createElement(tagName) {
592 return createMockDomNode(tagName);
593 },
594 execCommand() {
595 return true;
596 },
597 getElementById(id) {
598 return findDomNodesById(documentElement, id)[0] || null;
599 },
600 querySelectorAll(selector) {
601 if (typeof selector === "string" && selector.startsWith("#")) {
602 return findDomNodesById(documentElement, selector.slice(1));
603 }
604
605 return [];
606 }
607 };
608
609 const browser = {
610 runtime: {
611 async sendMessage(message) {
612 if (message?.type === "get_page_control_state") {
613 return {
614 ok: true,
615 control: {
616 mode: "running",
617 controlConnection: "connected"
618 },
619 page: null
620 };
621 }
622
623 return { ok: true };
624 },
625 onMessage: {
626 addListener(listener) {
627 runtimeMessageListeners.add(listener);
628 },
629 removeListener(listener) {
630 runtimeMessageListeners.delete(listener);
631 }
632 }
633 },
634 storage: {
635 local: {
636 async get(keys) {
637 if (Array.isArray(keys)) {
638 return Object.fromEntries(keys.map((key) => [key, storage[key]]));
639 }
640
641 if (typeof keys === "string") {
642 return {
643 [keys]: storage[keys]
644 };
645 }
646
647 return { ...storage };
648 },
649 async set(values) {
650 Object.assign(storage, values || {});
651 }
652 },
653 onChanged: {
654 addListener(listener) {
655 storageListeners.add(listener);
656 },
657 removeListener(listener) {
658 storageListeners.delete(listener);
659 }
660 }
661 }
662 };
663
664 function createWindow() {
665 const listeners = new Map();
666
667 return {
668 addEventListener(type, listener) {
669 if (!listeners.has(type)) {
670 listeners.set(type, new Set());
671 }
672 listeners.get(type)?.add(listener);
673 },
674 dispatchEvent(event) {
675 const handlers = listeners.get(event?.type);
676 for (const listener of handlers || []) {
677 listener(event);
678 }
679 return true;
680 },
681 removeEventListener(type, listener) {
682 listeners.get(type)?.delete(listener);
683 }
684 };
685 }
686
687 function execute() {
688 const windowObject = createWindow();
689 const context = {
690 URL,
691 URLSearchParams,
692 browser,
693 clearTimeout,
694 console,
695 CustomEvent: class CustomEvent {
696 constructor(type, init = {}) {
697 this.detail = init.detail;
698 this.type = type;
699 }
700 },
701 document,
702 globalThis: null,
703 location: {
704 href: "https://chatgpt.com/c/overlay-smoke"
705 },
706 setTimeout,
707 window: windowObject
708 };
709
710 context.globalThis = context;
711 vm.runInNewContext(contentScriptSource, context, {
712 filename: "content-script.js"
713 });
714 return context;
715 }
716
717 return {
718 execute,
719 getOverlayRoots() {
720 return findDomNodesById(documentElement, "__baaFirefoxPageControlOverlay__");
721 }
722 };
723}
724
725function parseSseFrames(text) {
726 return String(text || "")
727 .split(/\n\n+/u)
728 .map((chunk) => chunk.trim())
729 .filter(Boolean)
730 .map((chunk) => {
731 const lines = chunk.split("\n");
732 const eventLine = lines.find((line) => line.startsWith("event:"));
733 const dataLines = lines
734 .filter((line) => line.startsWith("data:"))
735 .map((line) => line.slice(5).trimStart());
736
737 return {
738 data: JSON.parse(dataLines.join("\n")),
739 event: eventLine ? eventLine.slice(6).trim() : null
740 };
741 });
742}
743
744function assertNoSecretLeak(text, secrets) {
745 for (const secret of secrets) {
746 assert.doesNotMatch(text, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"));
747 }
748}
749
750function getShellRuntimeDefaults(platform) {
751 switch (platform) {
752 case "chatgpt":
753 return {
754 actualUrl: "https://chatgpt.com/c/smoke",
755 shellUrl: "https://chatgpt.com/",
756 title: "Smoke ChatGPT"
757 };
758 case "gemini":
759 return {
760 actualUrl: "https://gemini.google.com/app",
761 shellUrl: "https://gemini.google.com/",
762 title: "Smoke Gemini"
763 };
764 case "claude":
765 default:
766 return {
767 actualUrl: "https://claude.ai/chats/smoke",
768 shellUrl: "https://claude.ai/",
769 title: "Smoke Claude"
770 };
771 }
772}
773
774function buildShellRuntime(platform, overrides = {}) {
775 const defaults = getShellRuntimeDefaults(platform);
776
777 return {
778 platform,
779 desired: {
780 exists: true,
781 shell_url: defaults.shellUrl,
782 source: "smoke",
783 reason: "smoke_test",
784 updated_at: 1710000002000,
785 last_action: "tab_open",
786 last_action_at: 1710000002100
787 },
788 actual: {
789 exists: true,
790 tab_id: 321,
791 url: defaults.actualUrl,
792 title: defaults.title,
793 window_id: 91,
794 active: true,
795 status: "complete",
796 discarded: false,
797 hidden: false,
798 healthy: true,
799 issue: null,
800 last_seen_at: 1710000002200,
801 last_ready_at: 1710000002300,
802 candidate_tab_id: null,
803 candidate_url: null
804 },
805 drift: {
806 aligned: true,
807 needs_restore: false,
808 unexpected_actual: false,
809 reason: "aligned"
810 },
811 ...overrides
812 };
813}
814
815function registerSelectors(map, selectors, element) {
816 for (const selector of selectors) {
817 map.set(selector, [element]);
818 }
819}
820
821function createMockElement(options = {}) {
822 const attributes = new Map(
823 Object.entries(options.attributes || {}).map(([key, value]) => [key.toLowerCase(), String(value)])
824 );
825
826 return {
827 disabled: options.disabled === true,
828 dispatchedEvents: [],
829 files: options.files || [],
830 focusCalls: 0,
831 getAttribute(name) {
832 return attributes.get(String(name || "").toLowerCase()) ?? null;
833 },
834 getBoundingClientRect() {
835 return options.visible === false
836 ? {
837 width: 0,
838 height: 0
839 }
840 : {
841 width: 120,
842 height: 32
843 };
844 },
845 isContentEditable: options.isContentEditable === true,
846 tagName: String(options.tagName || "DIV").toUpperCase(),
847 textContent: options.textContent || "",
848 type: options.type || "",
849 value: options.value || "",
850 focus() {
851 this.focusCalls += 1;
852 },
853 click() {
854 if (typeof options.onClick === "function") {
855 options.onClick(this);
856 }
857 },
858 dispatchEvent(event) {
859 this.dispatchedEvents.push(event);
860 return true;
861 }
862 };
863}
864
865function createDeliveryHarness(options = {}) {
866 const platform = options.platform || "chatgpt";
867 const adapter = getPlatformAdapter(platform);
868 if (!adapter) {
869 throw new Error(`unsupported harness platform: ${platform}`);
870 }
871
872 const selectorMap = new Map();
873 const state = {
874 bodyText: options.bodyText || "Shell ready",
875 now: 0,
876 readyState: options.pageReady === false ? "loading" : "complete",
877 url: options.url
878 || (platform === "claude" ? "https://claude.ai/#baa-shell" : "https://chatgpt.com/#baa-shell")
879 };
880 const body = {
881 get innerText() {
882 return state.bodyText;
883 },
884 set innerText(value) {
885 state.bodyText = String(value || "");
886 }
887 };
888 const document = {
889 body: options.pageReady === false ? null : body,
890 execCommand() {
891 return true;
892 },
893 querySelectorAll(selector) {
894 return selectorMap.get(selector) || [];
895 },
896 readyState: state.readyState
897 };
898 const main = createMockElement({
899 tagName: "main"
900 });
901 const composer = createMockElement({
902 tagName: "textarea",
903 value: options.initialComposerText || ""
904 });
905 const sendButton = createMockElement({
906 tagName: "button",
907 onClick: () => {
908 state.sendClicked = true;
909
910 if (options.confirmSend === false) {
911 return;
912 }
913
914 sendButton.disabled = true;
915 composer.value = "";
916 }
917 });
918
919 if (options.pageReady !== false) {
920 registerSelectors(selectorMap, adapter.readinessSelectors, main);
921 }
922 if (options.includeComposer !== false) {
923 registerSelectors(selectorMap, adapter.composerSelectors, composer);
924 }
925 if (options.includeSendButton !== false) {
926 registerSelectors(selectorMap, adapter.sendButtonSelectors, sendButton);
927 }
928
929 const runtime = createDeliveryRuntime({
930 env: {
931 assignFiles(input, files) {
932 input.files = files;
933
934 if (options.confirmUpload === false) {
935 return;
936 }
937
938 state.bodyText = `${state.bodyText}\n${files[0]?.name || ""}`.trim();
939 },
940 createChangeEvent() {
941 return {
942 type: "change"
943 };
944 },
945 createFile(bytes, filename, mimeType) {
946 return {
947 bytes,
948 name: filename,
949 type: mimeType
950 };
951 },
952 createInputEvent(data) {
953 return {
954 data,
955 type: "input"
956 };
957 },
958 document,
959 getComputedStyle() {
960 return {
961 display: "block",
962 opacity: "1",
963 visibility: "visible"
964 };
965 },
966 getLocationHref() {
967 return state.url;
968 },
969 now() {
970 return state.now;
971 },
972 async sleep(ms) {
973 state.now += Number(ms) || 0;
974 }
975 }
976 });
977
978 return {
979 adapter,
980 composer,
981 document,
982 runtime,
983 sendButton,
984 state
985 };
986}
987
988function sendPluginActionResult(socket, input) {
989 const shellRuntime = input.shell_runtime ?? (input.platform ? [buildShellRuntime(input.platform)] : []);
990 const results =
991 input.results
992 ?? shellRuntime.map((runtime) => ({
993 ok: true,
994 platform: runtime.platform,
995 restored: input.restored ?? false,
996 shell_runtime: runtime,
997 skipped: input.skipped ?? null,
998 tab_id: runtime.actual.tab_id
999 }));
1000
1001 socket.send(
1002 JSON.stringify({
1003 type: "action_result",
1004 requestId: input.requestId,
1005 action: input.action,
1006 command_type: input.commandType ?? input.type ?? input.action,
1007 accepted: input.accepted ?? true,
1008 completed: input.completed ?? true,
1009 failed: input.failed ?? false,
1010 reason: input.reason ?? null,
1011 target: {
1012 platform: input.platform ?? null,
1013 requested_platform: input.platform ?? null
1014 },
1015 result: {
1016 actual_count: shellRuntime.filter((runtime) => runtime.actual.exists).length,
1017 desired_count: shellRuntime.filter((runtime) => runtime.desired.exists).length,
1018 drift_count: shellRuntime.filter((runtime) => runtime.drift.aligned === false).length,
1019 failed_count: results.filter((entry) => entry.ok === false).length,
1020 ok_count: results.filter((entry) => entry.ok).length,
1021 platform_count: shellRuntime.length,
1022 restored_count: results.filter((entry) => entry.restored === true).length,
1023 skipped_reasons: results.map((entry) => entry.skipped).filter(Boolean)
1024 },
1025 results,
1026 shell_runtime: shellRuntime
1027 })
1028 );
1029}
1030
1031test("final message relay observer waits for ChatGPT stream completion and suppresses duplicates", () => {
1032 const relayState = createRelayState("chatgpt");
1033 const pageUrl = "https://chatgpt.com/c/conv-chatgpt-smoke";
1034 const url = "https://chatgpt.com/backend-api/conversation";
1035
1036 const firstRelay = observeSse(
1037 relayState,
1038 {
1039 url,
1040 reqBody: JSON.stringify({
1041 conversation_id: "conv-chatgpt-smoke"
1042 }),
1043 chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"in_progress","content":{"content_type":"text","parts":["half way there"]}}}',
1044 done: false,
1045 source: "page"
1046 },
1047 {
1048 observedAt: 1_710_000_001_000,
1049 pageUrl
1050 }
1051 );
1052 assert.equal(firstRelay, null);
1053
1054 const completedRelay = observeSse(
1055 relayState,
1056 {
1057 url,
1058 reqBody: JSON.stringify({
1059 conversation_id: "conv-chatgpt-smoke"
1060 }),
1061 chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
1062 done: true,
1063 source: "page"
1064 },
1065 {
1066 observedAt: 1_710_000_002_000,
1067 pageUrl
1068 }
1069 );
1070 assert.ok(completedRelay);
1071 assert.equal(completedRelay.payload.type, "browser.final_message");
1072 assert.equal(completedRelay.payload.platform, "chatgpt");
1073 assert.equal(completedRelay.payload.conversation_id, "conv-chatgpt-smoke");
1074 assert.equal(completedRelay.payload.assistant_message_id, "msg-chatgpt-smoke");
1075 assert.equal(completedRelay.payload.raw_text, "final ChatGPT answer");
1076
1077 rememberRelay(relayState, completedRelay);
1078
1079 const duplicateRelay = observeSse(
1080 relayState,
1081 {
1082 url,
1083 reqBody: JSON.stringify({
1084 conversation_id: "conv-chatgpt-smoke"
1085 }),
1086 chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
1087 done: true,
1088 source: "page"
1089 },
1090 {
1091 observedAt: 1_710_000_003_000,
1092 pageUrl
1093 }
1094 );
1095 assert.equal(duplicateRelay, null);
1096});
1097
1098test("final message relay observer prefers the latest ChatGPT root message over historical mapping entries", () => {
1099 const relayState = createRelayState("chatgpt");
1100 const pageUrl = "https://chatgpt.com/c/conv-chatgpt-new-turn";
1101 const url = "https://chatgpt.com/backend-api/conversation";
1102
1103 const relay = observeNetwork(
1104 relayState,
1105 {
1106 reqBody: JSON.stringify({
1107 conversation_id: "conv-chatgpt-new-turn"
1108 }),
1109 resBody: JSON.stringify({
1110 conversation_id: "conv-chatgpt-new-turn",
1111 mapping: {
1112 old_turn: {
1113 message: {
1114 id: "msg-chatgpt-old-turn",
1115 author: {
1116 role: "assistant"
1117 },
1118 status: "finished_successfully",
1119 end_turn: true,
1120 content: {
1121 content_type: "text",
1122 parts: ["old historical answer with many many extra words that should not win"]
1123 }
1124 }
1125 }
1126 },
1127 message: {
1128 id: "msg-chatgpt-new-turn",
1129 author: {
1130 role: "assistant"
1131 },
1132 status: "finished_successfully",
1133 end_turn: true,
1134 content: {
1135 content_type: "text",
1136 parts: ["new ChatGPT turn answer"]
1137 }
1138 }
1139 }),
1140 source: "page",
1141 url
1142 },
1143 {
1144 observedAt: 1_710_000_002_500,
1145 pageUrl
1146 }
1147 );
1148
1149 assert.ok(relay);
1150 assert.equal(relay.payload.type, "browser.final_message");
1151 assert.equal(relay.payload.platform, "chatgpt");
1152 assert.equal(relay.payload.conversation_id, "conv-chatgpt-new-turn");
1153 assert.equal(relay.payload.assistant_message_id, "msg-chatgpt-new-turn");
1154 assert.equal(relay.payload.raw_text, "new ChatGPT turn answer");
1155});
1156
1157test("final message relay observer extracts Gemini final text only after stream completion", () => {
1158 const relayState = createRelayState("gemini");
1159 const pageUrl = "https://gemini.google.com/app/conv-gemini-smoke";
1160 const url = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
1161 const reqBody = new URLSearchParams({
1162 "f.req": JSON.stringify([
1163 null,
1164 JSON.stringify([["Prompt from user"]])
1165 ])
1166 }).toString();
1167 const partialChunk = JSON.stringify([
1168 ["wrb.fr", "req-smoke", JSON.stringify([["partial response"]]), null, null, null, "generic"]
1169 ]);
1170 const finalChunk = JSON.stringify([
1171 ["wrb.fr", "req-smoke", JSON.stringify([["Gemini final answer with two lines.\n\nSecond paragraph."]]), null, null, null, "generic"]
1172 ]);
1173
1174 const firstRelay = observeSse(
1175 relayState,
1176 {
1177 url,
1178 reqBody,
1179 chunk: partialChunk,
1180 done: false,
1181 source: "page"
1182 },
1183 {
1184 observedAt: 1_710_000_004_000,
1185 pageUrl
1186 }
1187 );
1188 assert.equal(firstRelay, null);
1189
1190 const completedRelay = observeSse(
1191 relayState,
1192 {
1193 url,
1194 reqBody,
1195 chunk: finalChunk,
1196 done: true,
1197 source: "page"
1198 },
1199 {
1200 observedAt: 1_710_000_005_000,
1201 pageUrl
1202 }
1203 );
1204 assert.ok(completedRelay);
1205 assert.equal(completedRelay.payload.type, "browser.final_message");
1206 assert.equal(completedRelay.payload.platform, "gemini");
1207 assert.equal(completedRelay.payload.conversation_id, "conv-gemini-smoke");
1208 assert.equal(completedRelay.payload.raw_text, "Gemini final answer with two lines.\n\nSecond paragraph.");
1209 assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
1210});
1211
1212test("final message relay observer prefers Gemini assistant text over shell protocol fragments", () => {
1213 const relayState = createRelayState("gemini");
1214 const pageUrl = "https://gemini.google.com/app/conv-gemini-shell-fragment";
1215 const url = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
1216 const reqBody = new URLSearchParams({
1217 "f.req": JSON.stringify([
1218 null,
1219 JSON.stringify([["Reply with exactly: conductor-ok-731. No punctuation, no explanation."]])
1220 ])
1221 }).toString();
1222 const assistantChunk = JSON.stringify([
1223 ["wrb.fr", "req-smoke", JSON.stringify([["conductor-ok-731"]]), null, null, null, "generic"]
1224 ]);
1225 const protocolFragmentChunk = JSON.stringify([
1226 [
1227 "wrb.fr",
1228 "req-smoke",
1229 "[[[[2,15,2],1,0,[1774860674,754000000],80,80],[[2,4,2],1,8,[1774865703,894000000],25,23]],\"e6fa609c3fa255c0\"]",
1230 null,
1231 null,
1232 null,
1233 "generic"
1234 ]
1235 ]);
1236
1237 const firstRelay = observeSse(
1238 relayState,
1239 {
1240 url,
1241 reqBody,
1242 chunk: assistantChunk,
1243 done: false,
1244 source: "page"
1245 },
1246 {
1247 observedAt: 1_710_000_004_200,
1248 pageUrl
1249 }
1250 );
1251 assert.equal(firstRelay, null);
1252
1253 const completedRelay = observeSse(
1254 relayState,
1255 {
1256 url,
1257 reqBody,
1258 chunk: protocolFragmentChunk,
1259 done: true,
1260 source: "page"
1261 },
1262 {
1263 observedAt: 1_710_000_005_200,
1264 pageUrl
1265 }
1266 );
1267 assert.ok(completedRelay);
1268 assert.equal(completedRelay.payload.type, "browser.final_message");
1269 assert.equal(completedRelay.payload.platform, "gemini");
1270 assert.equal(completedRelay.payload.conversation_id, "conv-gemini-shell-fragment");
1271 assert.equal(completedRelay.payload.raw_text, "conductor-ok-731");
1272 assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
1273});
1274
1275test("final message relay observer extracts Claude completion text and metadata only after stream completion", () => {
1276 const relayState = createRelayState("claude");
1277 const pageUrl = "https://claude.ai/chats/conv-claude-smoke-page";
1278 const url = "https://claude.ai/api/organizations/org-smoke/chat_conversations/conv-claude-smoke/completion";
1279
1280 const firstRelay = observeSse(
1281 relayState,
1282 {
1283 url,
1284 chunk: [
1285 "event: completion",
1286 'data: {"type":"completion","completion":"Hello "}',
1287 ""
1288 ].join("\n"),
1289 done: false,
1290 source: "page"
1291 },
1292 {
1293 observedAt: 1_710_000_005_500,
1294 pageUrl
1295 }
1296 );
1297 assert.equal(firstRelay, null);
1298
1299 const secondRelay = observeSse(
1300 relayState,
1301 {
1302 url,
1303 chunk: [
1304 "event: completion",
1305 'data: {"type":"completion","completion":"world"}',
1306 ""
1307 ].join("\n"),
1308 done: false,
1309 source: "page"
1310 },
1311 {
1312 observedAt: 1_710_000_005_700,
1313 pageUrl
1314 }
1315 );
1316 assert.equal(secondRelay, null);
1317
1318 const completedRelay = observeSse(
1319 relayState,
1320 {
1321 url,
1322 chunk: [
1323 "event: completion",
1324 'data: {"type":"completion","completion":"","id":"chatcompl-claude-smoke","stop_reason":"end_turn","log_id":"log-claude-smoke","messageLimit":{"type":"within_limit"}}',
1325 ""
1326 ].join("\n"),
1327 done: true,
1328 source: "page"
1329 },
1330 {
1331 observedAt: 1_710_000_006_000,
1332 pageUrl
1333 }
1334 );
1335 assert.ok(completedRelay);
1336 assert.equal(completedRelay.payload.type, "browser.final_message");
1337 assert.equal(completedRelay.payload.platform, "claude");
1338 assert.equal(completedRelay.payload.conversation_id, "conv-claude-smoke");
1339 assert.equal(completedRelay.payload.assistant_message_id, "chatcompl-claude-smoke");
1340 assert.equal(completedRelay.payload.raw_text, "Hello world");
1341});
1342
1343test("final message relay network observer extracts Claude buffered completion text without metadata pollution", () => {
1344 const relayState = createRelayState("claude");
1345 const pageUrl = "https://claude.ai/chats/conv-claude-network-page";
1346 const url = "https://claude.ai/api/organizations/org-smoke/chat_conversations/conv-claude-network/completion";
1347 const resBody = [
1348 "event: completion",
1349 'data: {"type":"completion","completion":"Buffered "}',
1350 "",
1351 "event: completion",
1352 'data: {"type":"completion","completion":"Claude reply","id":"chatcompl-claude-network"}',
1353 "",
1354 "event: completion",
1355 'data: {"type":"completion","completion":"","stop":"\\n\\nHuman:","messageLimit":{"type":"within_limit","resetsAt":"2026-03-28T00:00:00.000Z"}}',
1356 ""
1357 ].join("\n");
1358
1359 const relay = observeNetwork(
1360 relayState,
1361 {
1362 url,
1363 resBody,
1364 source: "page"
1365 },
1366 {
1367 observedAt: 1_710_000_006_500,
1368 pageUrl
1369 }
1370 );
1371
1372 assert.ok(relay);
1373 assert.equal(relay.payload.type, "browser.final_message");
1374 assert.equal(relay.payload.platform, "claude");
1375 assert.equal(relay.payload.conversation_id, "conv-claude-network");
1376 assert.equal(relay.payload.assistant_message_id, "chatcompl-claude-network");
1377 assert.equal(relay.payload.raw_text, "Buffered Claude reply");
1378});
1379
1380test("controller accepts Claude non-shell page SSE without adopting the chat tab as shell", () => {
1381 const conversationId = "22222222-2222-4222-8222-222222222222";
1382 const harness = createControllerHarness();
1383
1384 harness.hooks.handlePageSse(
1385 {
1386 chunk: [
1387 "event: completion",
1388 'data: {"type":"completion","completion":"Claude final answer from non-shell page","id":"msg-claude-non-shell"}',
1389 ""
1390 ].join("\n"),
1391 done: true,
1392 platform: "claude",
1393 url: `https://claude.ai/api/organizations/11111111-1111-4111-8111-111111111111/chat_conversations/${conversationId}/completion`
1394 },
1395 {
1396 tab: {
1397 id: 42,
1398 title: "Smoke Claude Chat",
1399 url: `https://claude.ai/chat/${conversationId}`
1400 }
1401 }
1402 );
1403
1404 assert.equal(harness.hooks.state.trackedTabs.claude, null);
1405 assert.equal(harness.hooks.state.claudeState.tabId, 42);
1406 assert.equal(harness.hooks.state.claudeState.conversationId, conversationId);
1407 assert.ok(harness.hooks.state.claudeState.lastActivityAt > 0);
1408});
1409
1410test("controller tab_reload refreshes observer scripts on existing Claude chat tabs", async () => {
1411 const conversationId = "33333333-3333-4333-8333-333333333333";
1412 const harness = createControllerHarness({
1413 tabs: [
1414 {
1415 active: false,
1416 id: 11,
1417 lastAccessed: 100,
1418 status: "complete",
1419 title: "Claude Shell",
1420 url: "https://claude.ai/#baa-shell"
1421 },
1422 {
1423 active: true,
1424 id: 12,
1425 lastAccessed: 200,
1426 status: "complete",
1427 title: "Claude Chat",
1428 url: `https://claude.ai/chat/${conversationId}`
1429 }
1430 ]
1431 });
1432
1433 const result = await harness.hooks.runPluginManagementAction("tab_reload", {
1434 platform: "claude",
1435 source: "smoke_test"
1436 });
1437
1438 assert.deepEqual(harness.reloadedTabIds, [11]);
1439 assert.equal(result.action, "tab_reload");
1440 assert.deepEqual(Array.from(result.results[0].observer_refresh.refreshed_tab_ids), [12]);
1441 assert.ok(
1442 harness.executeScriptCalls.some((call) =>
1443 call.target?.tabId === 12
1444 && Array.isArray(call.files)
1445 && call.files.join(",") === "delivery-adapters.js,content-script.js"
1446 )
1447 );
1448 assert.ok(
1449 harness.executeScriptCalls.some((call) =>
1450 call.target?.tabId === 12
1451 && Array.isArray(call.files)
1452 && call.files.join(",") === "page-interceptor.js"
1453 && call.world === "MAIN"
1454 )
1455 );
1456});
1457
1458test("controller page-level pause suppresses only the paused page relay and resume re-enables it", async () => {
1459 const harness = createControllerHarness({
1460 finalMessageHelpers
1461 });
1462 const pausedSender = {
1463 tab: {
1464 id: 41,
1465 title: "Paused ChatGPT Page",
1466 url: "https://chatgpt.com/c/conv-page-paused"
1467 }
1468 };
1469 const otherSender = {
1470 tab: {
1471 id: 42,
1472 title: "Other ChatGPT Page",
1473 url: "https://chatgpt.com/c/conv-page-other"
1474 }
1475 };
1476
1477 const pauseResult = await harness.hooks.runPageControlAction("pause", pausedSender, {
1478 source: "smoke_test",
1479 reason: "pause_page_for_smoke"
1480 });
1481 assert.equal(pauseResult.page.platform, "chatgpt");
1482 assert.equal(pauseResult.page.tabId, 41);
1483 assert.equal(pauseResult.page.paused, true);
1484 assert.equal(pauseResult.page.conversationId, "conv-page-paused");
1485
1486 harness.hooks.handlePageSse(
1487 {
1488 chunk: 'data: {"conversation_id":"conv-page-paused","message":{"id":"msg-page-paused","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["paused page answer"]}}}',
1489 done: true,
1490 platform: "chatgpt",
1491 reqBody: JSON.stringify({
1492 conversation_id: "conv-page-paused"
1493 }),
1494 url: "https://chatgpt.com/backend-api/conversation"
1495 },
1496 pausedSender
1497 );
1498
1499 assert.equal(
1500 harness.sentMessages.filter((message) => message.type === "browser.final_message").length,
1501 0
1502 );
1503
1504 harness.hooks.handlePageSse(
1505 {
1506 chunk: 'data: {"conversation_id":"conv-page-other","message":{"id":"msg-page-other","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["other page answer"]}}}',
1507 done: true,
1508 platform: "chatgpt",
1509 reqBody: JSON.stringify({
1510 conversation_id: "conv-page-other"
1511 }),
1512 url: "https://chatgpt.com/backend-api/conversation"
1513 },
1514 otherSender
1515 );
1516
1517 const unpausedRelay = harness.sentMessages.find((message) =>
1518 message.type === "browser.final_message" && message.assistant_message_id === "msg-page-other"
1519 );
1520 assert.ok(unpausedRelay);
1521 assert.equal(unpausedRelay.conversation_id, "conv-page-other");
1522
1523 const resumeResult = await harness.hooks.runPageControlAction("resume", pausedSender, {
1524 source: "smoke_test",
1525 reason: "resume_page_for_smoke"
1526 });
1527 assert.equal(resumeResult.page.paused, false);
1528
1529 harness.hooks.handlePageSse(
1530 {
1531 chunk: 'data: {"conversation_id":"conv-page-paused","message":{"id":"msg-page-resumed","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["resumed page answer"]}}}',
1532 done: true,
1533 platform: "chatgpt",
1534 reqBody: JSON.stringify({
1535 conversation_id: "conv-page-paused"
1536 }),
1537 url: "https://chatgpt.com/backend-api/conversation"
1538 },
1539 pausedSender
1540 );
1541
1542 const resumedRelay = harness.sentMessages.find((message) =>
1543 message.type === "browser.final_message" && message.assistant_message_id === "msg-page-resumed"
1544 );
1545 assert.ok(resumedRelay);
1546 assert.equal(resumedRelay.conversation_id, "conv-page-paused");
1547});
1548
1549test("controller suppresses stale ChatGPT replay when the tab already points at a different conversation", () => {
1550 const harness = createControllerHarness({
1551 finalMessageHelpers
1552 });
1553 const sender = {
1554 tab: {
1555 id: 43,
1556 title: "Current ChatGPT Page",
1557 url: "https://chatgpt.com/c/conv-page-current"
1558 }
1559 };
1560
1561 harness.hooks.handlePageSse(
1562 {
1563 chunk: 'data: {"conversation_id":"conv-page-old","message":{"id":"msg-page-old","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["stale replay answer"]}}}',
1564 done: true,
1565 platform: "chatgpt",
1566 reqBody: JSON.stringify({
1567 conversation_id: "conv-page-old"
1568 }),
1569 url: "https://chatgpt.com/backend-api/conversation"
1570 },
1571 sender
1572 );
1573
1574 assert.equal(
1575 harness.sentMessages.filter((message) => message.type === "browser.final_message").length,
1576 0
1577 );
1578
1579 harness.hooks.handlePageSse(
1580 {
1581 chunk: 'data: {"conversation_id":"conv-page-current","message":{"id":"msg-page-current","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["current page answer"]}}}',
1582 done: true,
1583 platform: "chatgpt",
1584 reqBody: JSON.stringify({
1585 conversation_id: "conv-page-current"
1586 }),
1587 url: "https://chatgpt.com/backend-api/conversation"
1588 },
1589 sender
1590 );
1591
1592 const currentRelay = harness.sentMessages.find((message) =>
1593 message.type === "browser.final_message" && message.assistant_message_id === "msg-page-current"
1594 );
1595 assert.ok(currentRelay);
1596 assert.equal(currentRelay.conversation_id, "conv-page-current");
1597});
1598
1599test("controller restores recent final-message relay cache after reload and suppresses ChatGPT replay", async () => {
1600 const storage = {};
1601 const sender = {
1602 tab: {
1603 id: 44,
1604 title: "Reloaded ChatGPT Page",
1605 url: "https://chatgpt.com/c/conv-page-cache"
1606 }
1607 };
1608 const replayData = {
1609 chunk: 'data: {"conversation_id":"conv-page-cache","message":{"id":"msg-page-cache","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["cached replay answer"]}}}',
1610 done: true,
1611 platform: "chatgpt",
1612 reqBody: JSON.stringify({
1613 conversation_id: "conv-page-cache"
1614 }),
1615 url: "https://chatgpt.com/backend-api/conversation"
1616 };
1617 const storageKey = "baaFirefox.finalMessageRelayCache";
1618
1619 const firstHarness = createControllerHarness({
1620 finalMessageHelpers,
1621 storage
1622 });
1623 firstHarness.hooks.handlePageSse(replayData, sender);
1624
1625 assert.equal(
1626 firstHarness.sentMessages.filter((message) => message.type === "browser.final_message").length,
1627 1
1628 );
1629
1630 await firstHarness.hooks.persistFinalMessageRelayCache();
1631 assert.ok(Array.isArray(storage[storageKey]?.chatgpt));
1632 assert.ok(storage[storageKey].chatgpt.length > 0);
1633
1634 const secondHarness = createControllerHarness({
1635 finalMessageHelpers,
1636 storage
1637 });
1638 secondHarness.hooks.restoreFinalMessageRelayCache(storage[storageKey]);
1639 secondHarness.hooks.handlePageSse(replayData, sender);
1640
1641 assert.equal(
1642 secondHarness.sentMessages.filter((message) => message.type === "browser.final_message").length,
1643 0
1644 );
1645});
1646
1647test("controller blocks delivery bridge when the target page conversation is paused", async () => {
1648 const harness = createControllerHarness();
1649 const sender = {
1650 tab: {
1651 id: 51,
1652 title: "Paused Delivery Page",
1653 url: "https://chatgpt.com/c/conv-delivery-paused"
1654 }
1655 };
1656
1657 await harness.hooks.runPageControlAction("pause", sender, {
1658 source: "smoke_test",
1659 reason: "pause_delivery_target"
1660 });
1661
1662 await assert.rejects(
1663 () => harness.hooks.runDeliveryAction(
1664 {
1665 conversation_id: "conv-delivery-paused",
1666 plan_id: "plan-delivery-paused",
1667 platform: "chatgpt"
1668 },
1669 "inject_message"
1670 ),
1671 /页面已暂停/u
1672 );
1673});
1674
1675test("controller proxy delivery targets the observed business page instead of the shell tab", async () => {
1676 const harness = createControllerHarness({
1677 tabs: [
1678 {
1679 id: 11,
1680 title: "ChatGPT Shell",
1681 url: "https://chatgpt.com/#baa-shell"
1682 },
1683 {
1684 id: 51,
1685 title: "Delivery Target",
1686 url: "https://chatgpt.com/c/conv-proxy-target"
1687 }
1688 ]
1689 });
1690 const sender = {
1691 tab: {
1692 id: 51,
1693 title: "Delivery Target",
1694 url: "https://chatgpt.com/c/conv-proxy-target"
1695 }
1696 };
1697
1698 harness.hooks.handlePageBridgeReady({
1699 source: "smoke_test"
1700 }, sender);
1701 harness.hooks.handlePageNetwork({
1702 method: "POST",
1703 platform: "chatgpt",
1704 reqBody: JSON.stringify({
1705 action: "next",
1706 conversation_id: "conv-proxy-target",
1707 messages: [
1708 {
1709 author: {
1710 role: "user"
1711 },
1712 content: {
1713 content_type: "text",
1714 parts: ["original prompt"]
1715 },
1716 id: "msg-user-1"
1717 }
1718 ],
1719 model: "gpt-5.4",
1720 parent_message_id: "msg-parent-0"
1721 }),
1722 url: "https://chatgpt.com/backend-api/conversation"
1723 }, sender);
1724 harness.hooks.state.trackedTabs.chatgpt = 51;
1725 harness.hooks.state.lastHeaders.chatgpt = {
1726 authorization: "Bearer smoke-token",
1727 "openai-sentinel-chat-requirements-token": "sentinel-1"
1728 };
1729 harness.hooks.state.lastCredentialTabId.chatgpt = 51;
1730 harness.hooks.state.credentialCapturedAt.chatgpt = Date.now();
1731 harness.hooks.state.lastCredentialAt.chatgpt = Date.now();
1732 harness.hooks.state.lastCredentialUrl.chatgpt = "https://chatgpt.com/backend-api/conversation";
1733
1734 const result = await harness.hooks.runProxyDeliveryAction({
1735 assistant_message_id: "msg-assistant-source",
1736 conversation_id: "conv-proxy-target",
1737 message_text: "[BAA 执行结果]\nproxy delivery",
1738 plan_id: "plan-proxy-target",
1739 platform: "chatgpt",
1740 shell_page: false,
1741 tab_id: 51
1742 });
1743
1744 assert.equal(result.action, "proxy_delivery");
1745 assert.equal(result.results[0].tabId, 51);
1746 assert.equal(harness.tabMessages.length, 1);
1747 assert.equal(harness.tabMessages[0].tabId, 51);
1748 assert.equal(harness.tabMessages[0].payload.type, "baa_page_proxy_request");
1749 assert.equal(harness.tabMessages[0].payload.data.source, "proxy_delivery");
1750 assert.equal(harness.tabMessages[0].payload.data.path, "/backend-api/conversation");
1751 assert.equal(harness.tabMessages[0].payload.data.response_mode, "sse");
1752 assert.equal(harness.tabMessages[0].payload.data.body.conversation_id, "conv-proxy-target");
1753 assert.equal(harness.tabMessages[0].payload.data.body.parent_message_id, "msg-assistant-source");
1754 assert.equal(
1755 harness.tabMessages[0].payload.data.body.messages[0].content.parts[0],
1756 "[BAA 执行结果]\nproxy delivery"
1757 );
1758});
1759
1760test("controller proxy delivery fails closed when the target page route is missing", async () => {
1761 const harness = createControllerHarness({
1762 tabs: [
1763 {
1764 id: 11,
1765 title: "ChatGPT Shell",
1766 url: "https://chatgpt.com/#baa-shell"
1767 }
1768 ]
1769 });
1770
1771 await assert.rejects(
1772 () => harness.hooks.runProxyDeliveryAction({
1773 assistant_message_id: "msg-assistant-source",
1774 conversation_id: "conv-missing-target",
1775 message_text: "proxy delivery should fail closed",
1776 plan_id: "plan-proxy-missing",
1777 platform: "chatgpt",
1778 shell_page: false
1779 }),
1780 /delivery\.route_missing/u
1781 );
1782
1783 assert.equal(harness.tabMessages.length, 0);
1784});
1785
1786test("controller relays final_message for proxy_delivery SSE traffic", () => {
1787 const harness = createControllerHarness({
1788 finalMessageHelpers
1789 });
1790 const sender = {
1791 tab: {
1792 id: 51,
1793 title: "Delivery Target",
1794 url: "https://chatgpt.com/c/conv-proxy-relay"
1795 }
1796 };
1797
1798 harness.hooks.handlePageBridgeReady({
1799 source: "smoke_test"
1800 }, sender);
1801 harness.hooks.handlePageSse({
1802 chunk: 'data: {"conversation_id":"conv-proxy-relay","message":{"id":"msg-proxy-relay","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["proxy relay answer"]}}}',
1803 done: true,
1804 method: "POST",
1805 source: "proxy_delivery",
1806 url: "https://chatgpt.com/backend-api/conversation"
1807 }, sender);
1808
1809 const relay = harness.sentMessages.find((message) =>
1810 message.type === "browser.final_message" && message.assistant_message_id === "msg-proxy-relay"
1811 );
1812
1813 assert.ok(relay);
1814 assert.equal(relay.page_url, "https://chatgpt.com/c/conv-proxy-relay");
1815 assert.equal(relay.shell_page, false);
1816 assert.equal(relay.tab_id, 51);
1817});
1818
1819test("content script removes an existing overlay root before reinjection", () => {
1820 const harness = createContentScriptHarness();
1821
1822 harness.execute();
1823 assert.equal(harness.getOverlayRoots().length, 1);
1824
1825 harness.execute();
1826 assert.equal(harness.getOverlayRoots().length, 1);
1827});
1828
1829test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
1830 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
1831 const runtime = new ConductorRuntime(
1832 {
1833 nodeId: "mini-main",
1834 host: "mini",
1835 role: "primary",
1836 controlApiBase: "https://conductor.example.test",
1837 localApiBase: "http://127.0.0.1:0",
1838 sharedToken: "replace-me",
1839 paths: {
1840 runsDir: "/tmp/runs",
1841 stateDir
1842 }
1843 },
1844 {
1845 autoStartLoops: false,
1846 now: () => 100
1847 }
1848 );
1849
1850 let client = null;
1851
1852 try {
1853 const snapshot = await runtime.start();
1854 const baseUrl = snapshot.controlApi.localApiBase;
1855
1856 client = await connectFirefoxBridgeClient(
1857 snapshot.controlApi.firefoxWsUrl,
1858 "firefox-browser-control-smoke"
1859 );
1860
1861 assert.equal(client.initialSnapshot.snapshot.browser.client_count, 1);
1862 assert.equal(client.credentialRequest.reason, "hello");
1863
1864 client.socket.send(
1865 JSON.stringify({
1866 type: "credentials",
1867 platform: "claude",
1868 account: "smoke@example.com",
1869 credential_fingerprint: "fp-smoke-claude",
1870 freshness: "fresh",
1871 captured_at: 1710000001000,
1872 last_seen_at: 1710000001500,
1873 headers: {
1874 "anthropic-client-version": "smoke-client",
1875 cookie: "session=1",
1876 "x-csrf-token": "csrf-smoke"
1877 },
1878 shell_runtime: buildShellRuntime("claude"),
1879 timestamp: 1710000001000
1880 })
1881 );
1882 await client.queue.next(
1883 (message) => message.type === "state_snapshot" && message.reason === "credentials"
1884 );
1885
1886 client.socket.send(
1887 JSON.stringify({
1888 type: "api_endpoints",
1889 platform: "claude",
1890 account: "smoke@example.com",
1891 credential_fingerprint: "fp-smoke-claude",
1892 updated_at: 1710000002000,
1893 endpoints: [
1894 "GET /api/organizations",
1895 "GET /api/organizations/{id}/chat_conversations/{id}",
1896 "POST /api/organizations/{id}/chat_conversations/{id}/completion"
1897 ],
1898 endpoint_metadata: [
1899 {
1900 method: "GET",
1901 path: "/api/organizations",
1902 first_seen_at: 1710000001200,
1903 last_seen_at: 1710000002000
1904 }
1905 ],
1906 shell_runtime: buildShellRuntime("claude")
1907 })
1908 );
1909 await client.queue.next(
1910 (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
1911 );
1912
1913 const browserStatus = await fetchJson(`${baseUrl}/v1/browser`);
1914 assert.equal(browserStatus.response.status, 200);
1915 assert.equal(browserStatus.payload.data.bridge.client_count, 1);
1916 assert.equal(browserStatus.payload.data.current_client.client_id, "firefox-browser-control-smoke");
1917 assert.equal(browserStatus.payload.data.claude.ready, true);
1918 assert.equal(browserStatus.payload.data.claude.shell_runtime.platform, "claude");
1919 assert.equal(browserStatus.payload.data.records[0].view, "active_and_persisted");
1920 assert.equal(browserStatus.payload.data.records[0].live.credentials.header_count, 3);
1921 assert.equal(browserStatus.payload.data.records[0].live.shell_runtime.platform, "claude");
1922 assert.equal(browserStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-smoke-claude");
1923 assert.deepEqual(browserStatus.payload.data.records[0].persisted.endpoints, [
1924 "GET /api/organizations",
1925 "GET /api/organizations/{id}/chat_conversations/{id}",
1926 "POST /api/organizations/{id}/chat_conversations/{id}/completion"
1927 ]);
1928 assert.equal(browserStatus.payload.data.summary.status_counts.fresh, 1);
1929 assertNoSecretLeak(browserStatus.text, ["csrf-smoke", "session=1"]);
1930
1931 const openResultPromise = fetchJson(`${baseUrl}/v1/browser/claude/open`, {
1932 method: "POST",
1933 headers: {
1934 "content-type": "application/json"
1935 },
1936 body: JSON.stringify({
1937 client_id: "firefox-browser-control-smoke"
1938 })
1939 });
1940
1941 const openMessage = await client.queue.next((message) => message.type === "open_tab");
1942 assert.equal(openMessage.platform, "claude");
1943 sendPluginActionResult(client.socket, {
1944 action: "tab_open",
1945 platform: "claude",
1946 requestId: openMessage.requestId
1947 });
1948 const openResult = await openResultPromise;
1949 assert.equal(openResult.response.status, 200);
1950 assert.equal(openResult.payload.data.platform, "claude");
1951 assert.equal(openResult.payload.data.client_id, "firefox-browser-control-smoke");
1952 assert.equal(openResult.payload.data.accepted, true);
1953
1954 const pluginStatusPromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
1955 method: "POST",
1956 headers: {
1957 "content-type": "application/json"
1958 },
1959 body: JSON.stringify({
1960 action: "plugin_status",
1961 client_id: "firefox-browser-control-smoke"
1962 })
1963 });
1964
1965 const pluginStatusMessage = await client.queue.next(
1966 (message) => message.type === "plugin_status"
1967 );
1968 assert.equal(pluginStatusMessage.type, "plugin_status");
1969 sendPluginActionResult(client.socket, {
1970 action: "plugin_status",
1971 platform: "claude",
1972 requestId: pluginStatusMessage.requestId,
1973 type: "plugin_status"
1974 });
1975 const pluginStatusResult = await pluginStatusPromise;
1976 assert.equal(pluginStatusResult.response.status, 200);
1977 assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
1978 assert.equal(pluginStatusResult.payload.data.completed, true);
1979 assert.equal(pluginStatusResult.payload.data.result.platform_count, 1);
1980
1981 const wsReconnectPromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
1982 method: "POST",
1983 headers: {
1984 "content-type": "application/json"
1985 },
1986 body: JSON.stringify({
1987 action: "ws_reconnect",
1988 client_id: "firefox-browser-control-smoke"
1989 })
1990 });
1991
1992 const wsReconnectMessage = await client.queue.next(
1993 (message) => message.type === "ws_reconnect"
1994 );
1995 assert.equal(wsReconnectMessage.type, "ws_reconnect");
1996 sendPluginActionResult(client.socket, {
1997 action: "ws_reconnect",
1998 completed: false,
1999 requestId: wsReconnectMessage.requestId
2000 });
2001 const wsReconnectResult = await wsReconnectPromise;
2002 assert.equal(wsReconnectResult.response.status, 200);
2003 assert.equal(wsReconnectResult.payload.data.action, "ws_reconnect");
2004 assert.equal(wsReconnectResult.payload.data.completed, false);
2005 assert.equal(wsReconnectResult.payload.data.failed, false);
2006
2007 const tabRestorePromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
2008 method: "POST",
2009 headers: {
2010 "content-type": "application/json"
2011 },
2012 body: JSON.stringify({
2013 action: "tab_restore",
2014 client_id: "firefox-browser-control-smoke",
2015 platform: "claude",
2016 reason: "smoke-test"
2017 })
2018 });
2019
2020 const tabRestoreMessage = await client.queue.next(
2021 (message) => message.type === "tab_restore"
2022 );
2023 assert.equal(tabRestoreMessage.platform, "claude");
2024 assert.equal(tabRestoreMessage.reason, "smoke-test");
2025 sendPluginActionResult(client.socket, {
2026 action: "tab_restore",
2027 platform: "claude",
2028 requestId: tabRestoreMessage.requestId,
2029 restored: true,
2030 shell_runtime: [
2031 buildShellRuntime("claude", {
2032 desired: {
2033 exists: true,
2034 shell_url: "https://claude.ai/",
2035 source: "smoke",
2036 reason: "smoke_test",
2037 updated_at: 1710000002400,
2038 last_action: "tab_restore",
2039 last_action_at: 1710000002400
2040 },
2041 actual: {
2042 exists: true,
2043 tab_id: 654,
2044 url: "https://claude.ai/chats/restored",
2045 title: "Restored Claude",
2046 window_id: 91,
2047 active: false,
2048 status: "complete",
2049 discarded: false,
2050 hidden: false,
2051 healthy: true,
2052 issue: null,
2053 last_seen_at: 1710000002450,
2054 last_ready_at: 1710000002460,
2055 candidate_tab_id: null,
2056 candidate_url: null
2057 }
2058 })
2059 ]
2060 });
2061 const tabRestoreResult = await tabRestorePromise;
2062 assert.equal(tabRestoreResult.response.status, 200);
2063 assert.equal(tabRestoreResult.payload.data.action, "tab_restore");
2064 assert.equal(tabRestoreResult.payload.data.result.restored_count, 1);
2065
2066 const browserStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
2067 method: "POST",
2068 headers: {
2069 "content-type": "application/json"
2070 },
2071 body: JSON.stringify({
2072 platform: "claude",
2073 method: "POST",
2074 path: "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion",
2075 requestBody: {
2076 prompt: "Stream the bridge state."
2077 },
2078 requestId: "browser-stream-smoke",
2079 responseMode: "sse"
2080 })
2081 });
2082
2083 const browserStreamRequest = await client.queue.next(
2084 (message) => message.type === "api_request" && message.id === "browser-stream-smoke"
2085 );
2086 assert.equal(browserStreamRequest.method, "POST");
2087 assert.equal(
2088 browserStreamRequest.path,
2089 "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion"
2090 );
2091 assert.equal(browserStreamRequest.response_mode, "sse");
2092 assert.equal(browserStreamRequest.stream_id, "browser-stream-smoke");
2093 assert.equal(browserStreamRequest.body.prompt, "Stream the bridge state.");
2094
2095 client.socket.send(
2096 JSON.stringify({
2097 type: "stream_open",
2098 id: "browser-stream-smoke",
2099 stream_id: "browser-stream-smoke",
2100 status: 200,
2101 meta: {
2102 source: "smoke"
2103 }
2104 })
2105 );
2106 client.socket.send(
2107 JSON.stringify({
2108 type: "stream_event",
2109 id: "browser-stream-smoke",
2110 stream_id: "browser-stream-smoke",
2111 seq: 1,
2112 event: "message",
2113 data: {
2114 delta: "Bridge is streaming."
2115 },
2116 raw: 'data: {"delta":"Bridge is streaming."}'
2117 })
2118 );
2119 client.socket.send(
2120 JSON.stringify({
2121 type: "stream_end",
2122 id: "browser-stream-smoke",
2123 stream_id: "browser-stream-smoke",
2124 status: 200
2125 })
2126 );
2127
2128 const browserStreamResult = await browserStreamPromise;
2129 assert.equal(browserStreamResult.response.status, 200);
2130 assert.equal(
2131 browserStreamResult.response.headers.get("content-type"),
2132 "text/event-stream; charset=utf-8"
2133 );
2134 const browserStreamFrames = parseSseFrames(browserStreamResult.text);
2135 assert.deepEqual(
2136 browserStreamFrames.map((frame) => frame.event),
2137 ["stream_open", "stream_event", "stream_end"]
2138 );
2139 assert.equal(browserStreamFrames[0].data.request_id, "browser-stream-smoke");
2140 assert.equal(browserStreamFrames[0].data.response_mode, "sse");
2141 assert.equal(browserStreamFrames[1].data.seq, 1);
2142 assert.equal(browserStreamFrames[1].data.data.delta, "Bridge is streaming.");
2143 assert.equal(browserStreamFrames[2].data.stream_id, "browser-stream-smoke");
2144
2145 const cancelableRequestPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
2146 method: "POST",
2147 headers: {
2148 "content-type": "application/json"
2149 },
2150 body: JSON.stringify({
2151 platform: "claude",
2152 method: "GET",
2153 path: "/api/organizations",
2154 requestId: "browser-cancel-smoke"
2155 })
2156 });
2157
2158 const cancelableRequest = await client.queue.next(
2159 (message) => message.type === "api_request" && message.id === "browser-cancel-smoke"
2160 );
2161 assert.equal(cancelableRequest.method, "GET");
2162 assert.equal(cancelableRequest.path, "/api/organizations");
2163
2164 const cancelResult = await fetchJson(`${baseUrl}/v1/browser/request/cancel`, {
2165 method: "POST",
2166 headers: {
2167 "content-type": "application/json"
2168 },
2169 body: JSON.stringify({
2170 platform: "claude",
2171 requestId: "browser-cancel-smoke",
2172 reason: "smoke-test"
2173 })
2174 });
2175 assert.equal(cancelResult.response.status, 200);
2176 assert.equal(cancelResult.payload.data.status, "cancel_requested");
2177 assert.equal(cancelResult.payload.data.type, "request_cancel");
2178
2179 const cancelMessage = await client.queue.next(
2180 (message) => message.type === "request_cancel" && message.id === "browser-cancel-smoke"
2181 );
2182 assert.equal(cancelMessage.platform, "claude");
2183 assert.equal(cancelMessage.reason, "smoke-test");
2184
2185 client.socket.send(
2186 JSON.stringify({
2187 type: "api_response",
2188 id: "browser-cancel-smoke",
2189 ok: false,
2190 status: 499,
2191 error: "browser_request_cancelled"
2192 })
2193 );
2194
2195 const cancelledRequestResult = await cancelableRequestPromise;
2196 assert.equal(cancelledRequestResult.response.status, 499);
2197 assert.equal(cancelledRequestResult.payload.error, "browser_upstream_error");
2198
2199 const sendPromise = fetchJson(`${baseUrl}/v1/browser/claude/send`, {
2200 method: "POST",
2201 headers: {
2202 "content-type": "application/json"
2203 },
2204 body: JSON.stringify({
2205 prompt: "Summarize the current bridge state."
2206 })
2207 });
2208
2209 const orgRequest = await client.queue.next(
2210 (message) => message.type === "api_request" && message.path === "/api/organizations"
2211 );
2212 assert.equal(orgRequest.platform, "claude");
2213 client.socket.send(
2214 JSON.stringify({
2215 type: "api_response",
2216 id: orgRequest.id,
2217 ok: true,
2218 status: 200,
2219 body: {
2220 organizations: [
2221 {
2222 uuid: "org-smoke-1",
2223 name: "Smoke Org",
2224 is_default: true
2225 }
2226 ]
2227 }
2228 })
2229 );
2230
2231 const conversationListRequest = await client.queue.next(
2232 (message) => message.type === "api_request" && message.path === "/api/organizations/org-smoke-1/chat_conversations"
2233 );
2234 assert.equal(conversationListRequest.method, "GET");
2235 client.socket.send(
2236 JSON.stringify({
2237 type: "api_response",
2238 id: conversationListRequest.id,
2239 ok: true,
2240 status: 200,
2241 body: {
2242 chat_conversations: [
2243 {
2244 uuid: "conv-smoke-1",
2245 name: "Smoke Conversation",
2246 selected: true
2247 }
2248 ]
2249 }
2250 })
2251 );
2252
2253 const completionRequest = await client.queue.next(
2254 (message) =>
2255 message.type === "api_request"
2256 && message.path === "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion"
2257 );
2258 assert.equal(completionRequest.method, "POST");
2259 assert.equal(completionRequest.body.prompt, "Summarize the current bridge state.");
2260 client.socket.send(
2261 JSON.stringify({
2262 type: "api_response",
2263 id: completionRequest.id,
2264 ok: true,
2265 status: 202,
2266 body: {
2267 accepted: true,
2268 conversation_uuid: "conv-smoke-1",
2269 stop_reason: "end_turn"
2270 }
2271 })
2272 );
2273
2274 const sendResult = await sendPromise;
2275 assert.equal(sendResult.response.status, 200);
2276 assert.equal(sendResult.payload.data.organization.organization_id, "org-smoke-1");
2277 assert.equal(sendResult.payload.data.conversation.conversation_id, "conv-smoke-1");
2278 assert.equal(sendResult.payload.data.proxy.path, "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion");
2279 assert.equal(sendResult.payload.data.response.accepted, true);
2280
2281 const currentPromise = fetchJson(`${baseUrl}/v1/browser/claude/current`);
2282
2283 const currentOrgRequest = await client.queue.next(
2284 (message) => message.type === "api_request" && message.path === "/api/organizations"
2285 );
2286 client.socket.send(
2287 JSON.stringify({
2288 type: "api_response",
2289 id: currentOrgRequest.id,
2290 ok: true,
2291 status: 200,
2292 body: {
2293 organizations: [
2294 {
2295 uuid: "org-smoke-1",
2296 name: "Smoke Org",
2297 is_default: true
2298 }
2299 ]
2300 }
2301 })
2302 );
2303
2304 const currentConversationListRequest = await client.queue.next(
2305 (message) => message.type === "api_request" && message.path === "/api/organizations/org-smoke-1/chat_conversations"
2306 );
2307 client.socket.send(
2308 JSON.stringify({
2309 type: "api_response",
2310 id: currentConversationListRequest.id,
2311 ok: true,
2312 status: 200,
2313 body: {
2314 chat_conversations: [
2315 {
2316 uuid: "conv-smoke-1",
2317 name: "Smoke Conversation",
2318 selected: true
2319 }
2320 ]
2321 }
2322 })
2323 );
2324
2325 const currentDetailRequest = await client.queue.next(
2326 (message) =>
2327 message.type === "api_request"
2328 && message.path === "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1"
2329 );
2330 client.socket.send(
2331 JSON.stringify({
2332 type: "api_response",
2333 id: currentDetailRequest.id,
2334 ok: true,
2335 status: 200,
2336 body: {
2337 conversation: {
2338 uuid: "conv-smoke-1",
2339 name: "Smoke Conversation"
2340 },
2341 messages: [
2342 {
2343 uuid: "msg-smoke-user",
2344 sender: "human",
2345 text: "Summarize the current bridge state."
2346 },
2347 {
2348 uuid: "msg-smoke-assistant",
2349 sender: "assistant",
2350 content: [
2351 {
2352 text: "Bridge is connected and Claude proxy is ready."
2353 }
2354 ]
2355 }
2356 ]
2357 }
2358 })
2359 );
2360
2361 const currentResult = await currentPromise;
2362 assert.equal(currentResult.response.status, 200);
2363 assert.equal(currentResult.payload.data.organization.organization_id, "org-smoke-1");
2364 assert.equal(currentResult.payload.data.conversation.conversation_id, "conv-smoke-1");
2365 assert.equal(currentResult.payload.data.messages.length, 2);
2366 assert.equal(currentResult.payload.data.messages[0].role, "user");
2367 assert.equal(currentResult.payload.data.messages[0].content, "Summarize the current bridge state.");
2368 assert.equal(currentResult.payload.data.messages[1].role, "assistant");
2369 assert.equal(currentResult.payload.data.messages[1].content, "Bridge is connected and Claude proxy is ready.");
2370 assert.equal(currentResult.payload.data.proxy.status, 200);
2371
2372 client.socket.send(
2373 JSON.stringify({
2374 type: "credentials",
2375 platform: "chatgpt",
2376 account: "smoke@example.com",
2377 credential_fingerprint: "fp-smoke-chatgpt",
2378 freshness: "fresh",
2379 captured_at: 1710000003000,
2380 last_seen_at: 1710000003500,
2381 headers: {
2382 authorization: "Bearer chatgpt-auth-secret",
2383 cookie: "__Secure-next-auth.session-token=chatgpt-session-secret",
2384 "openai-sentinel-chat-requirements-token": "chatgpt-sentinel-secret"
2385 },
2386 shell_runtime: buildShellRuntime("chatgpt"),
2387 timestamp: 1710000003000
2388 })
2389 );
2390 await client.queue.next(
2391 (message) => message.type === "state_snapshot" && message.reason === "credentials"
2392 );
2393
2394 client.socket.send(
2395 JSON.stringify({
2396 type: "api_endpoints",
2397 platform: "chatgpt",
2398 account: "smoke@example.com",
2399 credential_fingerprint: "fp-smoke-chatgpt",
2400 updated_at: 1710000003600,
2401 endpoints: [
2402 "GET /backend-api/models",
2403 "POST /backend-api/conversation"
2404 ],
2405 endpoint_metadata: [
2406 {
2407 method: "GET",
2408 path: "/backend-api/models",
2409 first_seen_at: 1710000003200,
2410 last_seen_at: 1710000003600
2411 }
2412 ],
2413 shell_runtime: buildShellRuntime("chatgpt")
2414 })
2415 );
2416 await client.queue.next(
2417 (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
2418 );
2419
2420 const chatgptStatus = await fetchJson(`${baseUrl}/v1/browser?platform=chatgpt`);
2421 assert.equal(chatgptStatus.response.status, 200);
2422 assert.equal(chatgptStatus.payload.data.records.length, 1);
2423 assert.equal(chatgptStatus.payload.data.records[0].platform, "chatgpt");
2424 assert.equal(chatgptStatus.payload.data.records[0].live.request_hooks.endpoint_count, 2);
2425 assert.equal(chatgptStatus.payload.data.records[0].live.shell_runtime.platform, "chatgpt");
2426 assert.equal(chatgptStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-smoke-chatgpt");
2427 assert.equal(chatgptStatus.payload.data.summary.status_counts.fresh, 1);
2428 assertNoSecretLeak(chatgptStatus.text, [
2429 "chatgpt-auth-secret",
2430 "chatgpt-session-secret",
2431 "chatgpt-sentinel-secret"
2432 ]);
2433
2434 const chatgptBufferedResultPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
2435 method: "POST",
2436 headers: {
2437 "content-type": "application/json"
2438 },
2439 body: JSON.stringify({
2440 platform: "chatgpt",
2441 method: "GET",
2442 path: "/backend-api/models",
2443 requestId: "chatgpt-buffered-smoke"
2444 })
2445 });
2446
2447 const chatgptBufferedRequest = await client.queue.next(
2448 (message) => message.type === "api_request" && message.id === "chatgpt-buffered-smoke"
2449 );
2450 assert.equal(chatgptBufferedRequest.platform, "chatgpt");
2451 assert.equal(chatgptBufferedRequest.method, "GET");
2452 assert.equal(chatgptBufferedRequest.path, "/backend-api/models");
2453
2454 client.socket.send(
2455 JSON.stringify({
2456 type: "api_response",
2457 id: "chatgpt-buffered-smoke",
2458 ok: true,
2459 status: 200,
2460 body: {
2461 models: [
2462 {
2463 slug: "gpt-5.4"
2464 }
2465 ]
2466 }
2467 })
2468 );
2469
2470 const chatgptBufferedResult = await chatgptBufferedResultPromise;
2471 assert.equal(chatgptBufferedResult.response.status, 200);
2472 assert.equal(chatgptBufferedResult.payload.data.request_mode, "api_request");
2473 assert.equal(chatgptBufferedResult.payload.data.proxy.path, "/backend-api/models");
2474 assert.equal(chatgptBufferedResult.payload.data.response.models[0].slug, "gpt-5.4");
2475
2476 const chatgptStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
2477 method: "POST",
2478 headers: {
2479 "content-type": "application/json"
2480 },
2481 body: JSON.stringify({
2482 platform: "chatgpt",
2483 method: "POST",
2484 path: "/backend-api/conversation",
2485 requestBody: {
2486 prompt: "Stream ChatGPT bridge state."
2487 },
2488 requestId: "chatgpt-stream-smoke",
2489 responseMode: "sse"
2490 })
2491 });
2492
2493 const chatgptStreamRequest = await client.queue.next(
2494 (message) => message.type === "api_request" && message.id === "chatgpt-stream-smoke"
2495 );
2496 assert.equal(chatgptStreamRequest.platform, "chatgpt");
2497 assert.equal(chatgptStreamRequest.method, "POST");
2498 assert.equal(chatgptStreamRequest.path, "/backend-api/conversation");
2499 assert.equal(chatgptStreamRequest.response_mode, "sse");
2500
2501 client.socket.send(
2502 JSON.stringify({
2503 type: "stream_open",
2504 id: "chatgpt-stream-smoke",
2505 stream_id: "chatgpt-stream-smoke",
2506 status: 200,
2507 meta: {
2508 source: "smoke-chatgpt"
2509 }
2510 })
2511 );
2512 client.socket.send(
2513 JSON.stringify({
2514 type: "stream_event",
2515 id: "chatgpt-stream-smoke",
2516 stream_id: "chatgpt-stream-smoke",
2517 seq: 1,
2518 event: "message",
2519 data: {
2520 delta: "ChatGPT is streaming."
2521 },
2522 raw: 'data: {"delta":"ChatGPT is streaming."}'
2523 })
2524 );
2525 client.socket.send(
2526 JSON.stringify({
2527 type: "stream_end",
2528 id: "chatgpt-stream-smoke",
2529 stream_id: "chatgpt-stream-smoke",
2530 status: 200
2531 })
2532 );
2533
2534 const chatgptStreamResult = await chatgptStreamPromise;
2535 assert.equal(chatgptStreamResult.response.status, 200);
2536 assert.equal(
2537 chatgptStreamResult.response.headers.get("content-type"),
2538 "text/event-stream; charset=utf-8"
2539 );
2540 const chatgptStreamFrames = parseSseFrames(chatgptStreamResult.text);
2541 assert.deepEqual(
2542 chatgptStreamFrames.map((frame) => frame.event),
2543 ["stream_open", "stream_event", "stream_end"]
2544 );
2545 assert.equal(chatgptStreamFrames[0].data.request_id, "chatgpt-stream-smoke");
2546 assert.equal(chatgptStreamFrames[1].data.seq, 1);
2547 assert.equal(chatgptStreamFrames[1].data.data.delta, "ChatGPT is streaming.");
2548
2549 const chatgptCancelableRequestPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
2550 method: "POST",
2551 headers: {
2552 "content-type": "application/json"
2553 },
2554 body: JSON.stringify({
2555 platform: "chatgpt",
2556 method: "GET",
2557 path: "/backend-api/models",
2558 requestId: "chatgpt-cancel-smoke"
2559 })
2560 });
2561
2562 const chatgptCancelableRequest = await client.queue.next(
2563 (message) => message.type === "api_request" && message.id === "chatgpt-cancel-smoke"
2564 );
2565 assert.equal(chatgptCancelableRequest.platform, "chatgpt");
2566 assert.equal(chatgptCancelableRequest.path, "/backend-api/models");
2567
2568 const chatgptCancelResult = await fetchJson(`${baseUrl}/v1/browser/request/cancel`, {
2569 method: "POST",
2570 headers: {
2571 "content-type": "application/json"
2572 },
2573 body: JSON.stringify({
2574 platform: "chatgpt",
2575 requestId: "chatgpt-cancel-smoke",
2576 reason: "smoke-test"
2577 })
2578 });
2579 assert.equal(chatgptCancelResult.response.status, 200);
2580 assert.equal(chatgptCancelResult.payload.data.status, "cancel_requested");
2581 assert.equal(chatgptCancelResult.payload.data.type, "request_cancel");
2582
2583 const chatgptCancelMessage = await client.queue.next(
2584 (message) => message.type === "request_cancel" && message.id === "chatgpt-cancel-smoke"
2585 );
2586 assert.equal(chatgptCancelMessage.platform, "chatgpt");
2587 assert.equal(chatgptCancelMessage.reason, "smoke-test");
2588
2589 client.socket.send(
2590 JSON.stringify({
2591 type: "api_response",
2592 id: "chatgpt-cancel-smoke",
2593 ok: false,
2594 status: 499,
2595 error: "browser_request_cancelled"
2596 })
2597 );
2598
2599 const chatgptCancelledRequestResult = await chatgptCancelableRequestPromise;
2600 assert.equal(chatgptCancelledRequestResult.response.status, 499);
2601 assert.equal(chatgptCancelledRequestResult.payload.error, "browser_upstream_error");
2602 } finally {
2603 client?.queue.stop();
2604
2605 if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
2606 client.socket.close(1000, "done");
2607 }
2608
2609 await runtime.stop();
2610 rmSync(stateDir, {
2611 force: true,
2612 recursive: true
2613 });
2614 }
2615});
2616
2617test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
2618 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
2619 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
2620 const runtime = new ConductorRuntime(
2621 {
2622 nodeId: "mini-main",
2623 host: "mini",
2624 role: "primary",
2625 controlApiBase: "https://conductor.example.test",
2626 localApiBase: "http://127.0.0.1:0",
2627 sharedToken: "replace-me",
2628 paths: {
2629 runsDir: "/tmp/runs",
2630 stateDir
2631 }
2632 },
2633 {
2634 autoStartLoops: false,
2635 now: () => 100
2636 }
2637 );
2638
2639 let client = null;
2640
2641 try {
2642 const snapshot = await runtime.start();
2643 const baseUrl = snapshot.controlApi.localApiBase;
2644 client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-smoke");
2645 const execCommand = "i=1; while [ $i -le 260 ]; do printf 'line-%s\\n' \"$i\"; i=$((i+1)); done";
2646
2647 client.socket.send(
2648 JSON.stringify({
2649 type: "browser.final_message",
2650 platform: "chatgpt",
2651 conversation_id: "conv-delivery-smoke",
2652 assistant_message_id: "msg-delivery-smoke",
2653 page_title: "Delivery Target",
2654 page_url: "https://chatgpt.com/c/conv-delivery-smoke",
2655 raw_text: [
2656 "```baa",
2657 `@conductor::exec::${JSON.stringify({
2658 command: execCommand,
2659 cwd: hostOpsDir
2660 })}`,
2661 "```"
2662 ].join("\n"),
2663 observed_at: 1710000010000,
2664 shell_page: false,
2665 tab_id: 51
2666 })
2667 );
2668
2669 await expectQueueTimeout(
2670 client.queue,
2671 (message) => message.type === "browser.upload_artifacts",
2672 700
2673 );
2674
2675 const proxyDelivery = await client.queue.next(
2676 (message) => message.type === "browser.proxy_delivery"
2677 );
2678 assert.equal(proxyDelivery.platform, "chatgpt");
2679 assert.equal(proxyDelivery.conversation_id, "conv-delivery-smoke");
2680 assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
2681 assert.equal(proxyDelivery.shell_page, false);
2682 assert.equal(proxyDelivery.target_tab_id, 51);
2683 assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
2684 assert.match(proxyDelivery.message_text, /line-1/u);
2685 assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
2686 assert.match(proxyDelivery.message_text, /超长截断$/u);
2687
2688 await expectQueueTimeout(
2689 client.queue,
2690 (message) => message.type === "browser.inject_message" || message.type === "browser.send_message",
2691 700
2692 );
2693
2694 sendPluginActionResult(client.socket, {
2695 action: "proxy_delivery",
2696 commandType: "browser.proxy_delivery",
2697 platform: "chatgpt",
2698 requestId: proxyDelivery.requestId,
2699 type: "browser.proxy_delivery"
2700 });
2701
2702 const browserStatus = await waitForCondition(async () => {
2703 const result = await fetchJson(`${baseUrl}/v1/browser`);
2704 assert.equal(result.response.status, 200);
2705 assert.equal(result.payload.data.delivery.last_session.stage, "completed");
2706 return result;
2707 });
2708
2709 assert.equal(browserStatus.payload.data.delivery.last_session.platform, "chatgpt");
2710 assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
2711 assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
2712 assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-smoke");
2713 assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 51);
2714 assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
2715 assert.equal(browserStatus.payload.data.delivery.last_route.tab_id, 51);
2716 assert.ok(
2717 browserStatus.payload.data.delivery.last_session.source_line_count
2718 > browserStatus.payload.data.delivery.last_session.message_line_count
2719 );
2720 } finally {
2721 client?.queue.stop();
2722 client?.socket.close(1000, "done");
2723 await runtime.stop();
2724 rmSync(stateDir, {
2725 force: true,
2726 recursive: true
2727 });
2728 rmSync(hostOpsDir, {
2729 force: true,
2730 recursive: true
2731 });
2732 }
2733});
2734
2735test("browser delivery bridge falls back to DOM delivery on the routed page when proxy delivery is rejected", async () => {
2736 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-"));
2737 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-host-"));
2738 const runtime = new ConductorRuntime(
2739 {
2740 nodeId: "mini-main",
2741 host: "mini",
2742 role: "primary",
2743 controlApiBase: "https://conductor.example.test",
2744 localApiBase: "http://127.0.0.1:0",
2745 sharedToken: "replace-me",
2746 paths: {
2747 runsDir: "/tmp/runs",
2748 stateDir
2749 }
2750 },
2751 {
2752 autoStartLoops: false,
2753 now: () => 100
2754 }
2755 );
2756
2757 let client = null;
2758
2759 try {
2760 const snapshot = await runtime.start();
2761 const baseUrl = snapshot.controlApi.localApiBase;
2762 client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-fail");
2763
2764 client.socket.send(
2765 JSON.stringify({
2766 type: "browser.final_message",
2767 platform: "chatgpt",
2768 conversation_id: "conv-delivery-fail",
2769 assistant_message_id: "msg-delivery-fail",
2770 page_title: "Fallback Target",
2771 page_url: "https://chatgpt.com/c/conv-delivery-fail",
2772 raw_text: [
2773 "```baa",
2774 `@conductor::exec::{"command":"printf 'artifact-fail\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
2775 "```"
2776 ].join("\n"),
2777 observed_at: 1710000020000,
2778 shell_page: false,
2779 tab_id: 61
2780 })
2781 );
2782
2783 await expectQueueTimeout(
2784 client.queue,
2785 (message) => message.type === "browser.upload_artifacts",
2786 700
2787 );
2788
2789 const proxyDelivery = await client.queue.next(
2790 (message) => message.type === "browser.proxy_delivery"
2791 );
2792 sendPluginActionResult(client.socket, {
2793 action: "proxy_delivery",
2794 commandType: "browser.proxy_delivery",
2795 failed: true,
2796 platform: "chatgpt",
2797 reason: "delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first",
2798 requestId: proxyDelivery.requestId,
2799 type: "browser.proxy_delivery"
2800 });
2801
2802 const injectMessage = await client.queue.next(
2803 (message) => message.type === "browser.inject_message"
2804 );
2805 assert.equal(injectMessage.target_tab_id, 61);
2806 assert.equal(injectMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
2807 sendPluginActionResult(client.socket, {
2808 action: "inject_message",
2809 commandType: "browser.inject_message",
2810 platform: "chatgpt",
2811 requestId: injectMessage.requestId,
2812 type: "browser.inject_message"
2813 });
2814
2815 const sendMessage = await client.queue.next(
2816 (message) => message.type === "browser.send_message"
2817 );
2818 assert.equal(sendMessage.target_tab_id, 61);
2819 assert.equal(sendMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
2820 sendPluginActionResult(client.socket, {
2821 action: "send_message",
2822 commandType: "browser.send_message",
2823 platform: "chatgpt",
2824 requestId: sendMessage.requestId,
2825 type: "browser.send_message"
2826 });
2827
2828 const browserStatus = await waitForCondition(async () => {
2829 const result = await fetchJson(`${baseUrl}/v1/browser`);
2830 assert.equal(result.response.status, 200);
2831 assert.equal(result.payload.data.delivery.last_session.stage, "completed");
2832 return result;
2833 });
2834
2835 assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "dom_fallback");
2836 assert.match(browserStatus.payload.data.delivery.last_session.proxy_failed_reason, /template_missing/u);
2837 assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
2838 assert.ok(browserStatus.payload.data.delivery.last_session.send_started_at);
2839 assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 61);
2840 assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-fail");
2841 } finally {
2842 client?.queue.stop();
2843 client?.socket.close(1000, "done");
2844 await runtime.stop();
2845 rmSync(stateDir, {
2846 force: true,
2847 recursive: true
2848 });
2849 rmSync(hostOpsDir, {
2850 force: true,
2851 recursive: true
2852 });
2853 }
2854});
2855
2856test("browser delivery bridge fails closed when the business-page target route is missing", async () => {
2857 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-"));
2858 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-host-"));
2859 const runtime = new ConductorRuntime(
2860 {
2861 nodeId: "mini-main",
2862 host: "mini",
2863 role: "primary",
2864 controlApiBase: "https://conductor.example.test",
2865 localApiBase: "http://127.0.0.1:0",
2866 sharedToken: "replace-me",
2867 paths: {
2868 runsDir: "/tmp/runs",
2869 stateDir
2870 }
2871 },
2872 {
2873 autoStartLoops: false,
2874 now: () => 100
2875 }
2876 );
2877
2878 let client = null;
2879
2880 try {
2881 const snapshot = await runtime.start();
2882 const baseUrl = snapshot.controlApi.localApiBase;
2883 client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-missing-route");
2884
2885 client.socket.send(
2886 JSON.stringify({
2887 type: "browser.final_message",
2888 platform: "chatgpt",
2889 conversation_id: "conv-delivery-missing-route",
2890 assistant_message_id: "msg-delivery-missing-route",
2891 raw_text: [
2892 "```baa",
2893 `@conductor::exec::{"command":"printf 'missing-route\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
2894 "```"
2895 ].join("\n"),
2896 observed_at: 1710000025000
2897 })
2898 );
2899
2900 await expectQueueTimeout(
2901 client.queue,
2902 (message) =>
2903 message.type === "browser.proxy_delivery"
2904 || message.type === "browser.inject_message"
2905 || message.type === "browser.send_message",
2906 700
2907 );
2908
2909 const browserStatus = await waitForCondition(async () => {
2910 const result = await fetchJson(`${baseUrl}/v1/browser`);
2911 assert.equal(result.response.status, 200);
2912 assert.equal(result.payload.data.delivery.last_session.stage, "failed");
2913 return result;
2914 });
2915
2916 assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /delivery\.route_missing/u);
2917 assert.equal(browserStatus.payload.data.delivery.last_route, null);
2918 } finally {
2919 client?.queue.stop();
2920 client?.socket.close(1000, "done");
2921 await runtime.stop();
2922 rmSync(stateDir, {
2923 force: true,
2924 recursive: true
2925 });
2926 rmSync(hostOpsDir, {
2927 force: true,
2928 recursive: true
2929 });
2930 }
2931});
2932
2933test("delivery adapters complete ChatGPT inject/send with explicit confirmation", async () => {
2934 const harness = createDeliveryHarness({
2935 platform: "chatgpt"
2936 });
2937
2938 const injectResult = await harness.runtime.handleCommand({
2939 command: "inject_message",
2940 platform: "chatgpt",
2941 retryAttempts: 1,
2942 text: "hello from delivery smoke",
2943 timeoutMs: 120
2944 });
2945 assert.equal(injectResult.ok, true);
2946 assert.equal(injectResult.details.confirmed_by, "composer_text_match");
2947 assert.equal(harness.composer.value, "hello from delivery smoke");
2948
2949 const sendResult = await harness.runtime.handleCommand({
2950 command: "send_message",
2951 platform: "chatgpt",
2952 retryAttempts: 1,
2953 timeoutMs: 120
2954 });
2955 assert.equal(sendResult.ok, true);
2956 assert.equal(sendResult.details.confirmed_by, "send_button_disabled");
2957 assert.equal(harness.sendButton.disabled, true);
2958 assert.equal(harness.composer.value, "");
2959});
2960
2961test("delivery adapters fail closed when page is not ready", async () => {
2962 const harness = createDeliveryHarness({
2963 pageReady: false,
2964 platform: "claude"
2965 });
2966
2967 const result = await harness.runtime.handleCommand({
2968 command: "inject_message",
2969 platform: "claude",
2970 retryAttempts: 1,
2971 text: "should not send",
2972 timeoutMs: 120
2973 });
2974
2975 assert.equal(result.ok, false);
2976 assert.equal(result.code, "page_not_ready");
2977 assert.match(result.reason, /delivery\.page_not_ready/u);
2978});
2979
2980test("delivery adapters fail closed when send click is not confirmed", async () => {
2981 const harness = createDeliveryHarness({
2982 confirmSend: false,
2983 platform: "chatgpt"
2984 });
2985 harness.composer.value = "still queued";
2986
2987 const result = await harness.runtime.handleCommand({
2988 command: "send_message",
2989 platform: "chatgpt",
2990 retryAttempts: 1,
2991 timeoutMs: 120
2992 });
2993
2994 assert.equal(result.ok, false);
2995 assert.equal(result.code, "send_not_confirmed");
2996 assert.match(result.reason, /delivery\.send_not_confirmed/u);
2997});
2998
2999test("browser control e2e smoke accepts browser.final_message and keeps recent relay snapshots", async () => {
3000 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-final-message-smoke-"));
3001 const runtime = new ConductorRuntime(
3002 {
3003 nodeId: "mini-main",
3004 host: "mini",
3005 role: "primary",
3006 controlApiBase: "https://conductor.example.test",
3007 localApiBase: "http://127.0.0.1:0",
3008 sharedToken: "replace-me",
3009 paths: {
3010 runsDir: "/tmp/runs",
3011 stateDir
3012 }
3013 },
3014 {
3015 autoStartLoops: false,
3016 now: () => 100
3017 }
3018 );
3019
3020 let client = null;
3021
3022 try {
3023 const snapshot = await runtime.start();
3024 client = await connectFirefoxBridgeClient(
3025 snapshot.controlApi.firefoxWsUrl,
3026 "firefox-final-message-smoke"
3027 );
3028
3029 client.socket.send(
3030 JSON.stringify({
3031 type: "browser.final_message",
3032 platform: "chatgpt",
3033 conversation_id: "conv-chatgpt-final-smoke",
3034 assistant_message_id: "msg-chatgpt-final-smoke",
3035 raw_text: "final ChatGPT browser relay",
3036 observed_at: 1_710_000_006_000
3037 })
3038 );
3039
3040 const firstSnapshot = await client.queue.next(
3041 (message) =>
3042 message.type === "state_snapshot"
3043 && message.reason === "browser.final_message"
3044 && message.snapshot.browser.clients.some((entry) =>
3045 entry.client_id === "firefox-final-message-smoke"
3046 && entry.final_messages.some((finalMessage) =>
3047 finalMessage.platform === "chatgpt"
3048 && finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
3049 && finalMessage.raw_text === "final ChatGPT browser relay"
3050 )
3051 )
3052 );
3053 const firstClient = firstSnapshot.snapshot.browser.clients.find(
3054 (entry) => entry.client_id === "firefox-final-message-smoke"
3055 );
3056 assert.ok(firstClient);
3057 assert.equal(firstClient.final_messages.length, 1);
3058
3059 const firstIngestSnapshot = await client.queue.next(
3060 (message) =>
3061 message.type === "state_snapshot"
3062 && message.reason === "instruction_ingest"
3063 && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
3064 );
3065 assert.equal(
3066 firstIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
3067 "ignored_no_instructions"
3068 );
3069
3070 client.socket.send(
3071 JSON.stringify({
3072 type: "browser.final_message",
3073 platform: "chatgpt",
3074 conversation_id: "conv-chatgpt-final-smoke",
3075 assistant_message_id: "msg-chatgpt-final-smoke",
3076 raw_text: "final ChatGPT browser relay",
3077 observed_at: 1_710_000_006_500
3078 })
3079 );
3080
3081 const duplicateSnapshot = await client.queue.next(
3082 (message) =>
3083 message.type === "state_snapshot"
3084 && message.reason === "browser.final_message"
3085 && message.snapshot.browser.clients.some((entry) =>
3086 entry.client_id === "firefox-final-message-smoke"
3087 && entry.final_messages.some((finalMessage) =>
3088 finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
3089 )
3090 )
3091 );
3092 const duplicateClient = duplicateSnapshot.snapshot.browser.clients.find(
3093 (entry) => entry.client_id === "firefox-final-message-smoke"
3094 );
3095 assert.ok(duplicateClient);
3096 assert.equal(duplicateClient.final_messages.length, 1);
3097
3098 const duplicateIngestSnapshot = await client.queue.next(
3099 (message) =>
3100 message.type === "state_snapshot"
3101 && message.reason === "instruction_ingest"
3102 && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
3103 );
3104 assert.equal(
3105 duplicateIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
3106 "duplicate_message"
3107 );
3108
3109 client.socket.send(
3110 JSON.stringify({
3111 type: "browser.final_message",
3112 platform: "gemini",
3113 conversation_id: "conv-gemini-final-smoke",
3114 assistant_message_id: "synthetic_gemini_smoke",
3115 raw_text: "final Gemini browser relay",
3116 observed_at: 1_710_000_007_000
3117 })
3118 );
3119
3120 const secondSnapshot = await client.queue.next(
3121 (message) =>
3122 message.type === "state_snapshot"
3123 && message.reason === "browser.final_message"
3124 && message.snapshot.browser.clients.some((entry) =>
3125 entry.client_id === "firefox-final-message-smoke"
3126 && entry.final_messages.some((finalMessage) =>
3127 finalMessage.platform === "gemini"
3128 && finalMessage.raw_text === "final Gemini browser relay"
3129 )
3130 )
3131 );
3132 const secondClient = secondSnapshot.snapshot.browser.clients.find(
3133 (entry) => entry.client_id === "firefox-final-message-smoke"
3134 );
3135 assert.ok(secondClient);
3136 assert.equal(secondClient.final_messages.length, 2);
3137
3138 const secondIngestSnapshot = await client.queue.next(
3139 (message) =>
3140 message.type === "state_snapshot"
3141 && message.reason === "instruction_ingest"
3142 && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "synthetic_gemini_smoke"
3143 );
3144 assert.equal(
3145 secondIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
3146 "ignored_no_instructions"
3147 );
3148 } finally {
3149 client?.queue.stop();
3150
3151 if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
3152 client.socket.close(1000, "done");
3153 }
3154
3155 await runtime.stop();
3156 rmSync(stateDir, {
3157 force: true,
3158 recursive: true
3159 });
3160 }
3161});
3162
3163test("browser control e2e smoke keeps persisted browser metadata readable across disconnect and restart", async () => {
3164 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-persistence-smoke-"));
3165 const createRuntime = () =>
3166 new ConductorRuntime(
3167 {
3168 nodeId: "mini-main",
3169 host: "mini",
3170 role: "primary",
3171 controlApiBase: "https://conductor.example.test",
3172 localApiBase: "http://127.0.0.1:0",
3173 sharedToken: "replace-me",
3174 paths: {
3175 runsDir: "/tmp/runs",
3176 stateDir
3177 }
3178 },
3179 {
3180 autoStartLoops: false,
3181 now: () => 100
3182 }
3183 );
3184
3185 let runtime = createRuntime();
3186 let client = null;
3187
3188 try {
3189 const snapshot = await runtime.start();
3190 const baseUrl = snapshot.controlApi.localApiBase;
3191 client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-persist-smoke");
3192
3193 client.socket.send(
3194 JSON.stringify({
3195 type: "credentials",
3196 platform: "claude",
3197 account: "persist@example.com",
3198 credential_fingerprint: "fp-claude-persist",
3199 freshness: "fresh",
3200 captured_at: 1710000001000,
3201 last_seen_at: 1710000001500,
3202 headers: {
3203 cookie: "session=persist-secret",
3204 "x-csrf-token": "csrf-persist-secret"
3205 }
3206 })
3207 );
3208 await client.queue.next(
3209 (message) => message.type === "state_snapshot" && message.reason === "credentials"
3210 );
3211
3212 client.socket.send(
3213 JSON.stringify({
3214 type: "api_endpoints",
3215 platform: "claude",
3216 account: "persist@example.com",
3217 credential_fingerprint: "fp-claude-persist",
3218 updated_at: 1710000002000,
3219 endpoints: [
3220 "GET /api/organizations",
3221 "POST /api/organizations/{id}/chat_conversations/{id}/completion"
3222 ],
3223 endpoint_metadata: [
3224 {
3225 method: "GET",
3226 path: "/api/organizations",
3227 first_seen_at: 1710000001200,
3228 last_seen_at: 1710000002000
3229 }
3230 ]
3231 })
3232 );
3233 await client.queue.next(
3234 (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
3235 );
3236
3237 const connectedStatus = await fetchJson(
3238 `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
3239 );
3240 assert.equal(connectedStatus.response.status, 200);
3241 assert.equal(connectedStatus.payload.data.records.length, 1);
3242 assert.equal(connectedStatus.payload.data.records[0].view, "active_and_persisted");
3243 assert.equal(connectedStatus.payload.data.records[0].status, "fresh");
3244 assert.equal(connectedStatus.payload.data.summary.active_records, 1);
3245 assert.equal(connectedStatus.payload.data.records[0].live.credentials.account, "persist@example.com");
3246 assert.equal(
3247 connectedStatus.payload.data.records[0].persisted.credential_fingerprint,
3248 "fp-claude-persist"
3249 );
3250 assert.deepEqual(
3251 connectedStatus.payload.data.records[0].persisted.endpoints,
3252 [
3253 "GET /api/organizations",
3254 "POST /api/organizations/{id}/chat_conversations/{id}/completion"
3255 ]
3256 );
3257 assertNoSecretLeak(connectedStatus.text, ["persist-secret", "csrf-persist-secret"]);
3258
3259 const closePromise = waitForWebSocketClose(client.socket);
3260 client.socket.close(1000, "disconnect-persist");
3261 await closePromise;
3262 client.queue.stop();
3263 client = null;
3264
3265 const disconnectedStatus = await waitForCondition(async () => {
3266 const result = await fetchJson(
3267 `${baseUrl}/v1/browser?platform=claude&account=persist%40example.com`
3268 );
3269 assert.equal(result.response.status, 200);
3270 assert.equal(result.payload.data.bridge.client_count, 0);
3271 assert.equal(result.payload.data.records.length, 1);
3272 assert.equal(result.payload.data.records[0].view, "persisted_only");
3273 assert.equal(result.payload.data.records[0].status, "stale");
3274 assert.equal(result.payload.data.summary.persisted_only_records, 1);
3275 return result;
3276 });
3277 assert.equal(disconnectedStatus.payload.data.records[0].persisted.status, "stale");
3278 assertNoSecretLeak(disconnectedStatus.text, ["persist-secret", "csrf-persist-secret"]);
3279
3280 await runtime.stop();
3281 runtime = null;
3282
3283 runtime = createRuntime();
3284 const restartedSnapshot = await runtime.start();
3285 const restartedStatus = await fetchJson(
3286 `${restartedSnapshot.controlApi.localApiBase}/v1/browser?platform=claude&account=persist%40example.com`
3287 );
3288 assert.equal(restartedStatus.response.status, 200);
3289 assert.equal(restartedStatus.payload.data.bridge.client_count, 0);
3290 assert.equal(restartedStatus.payload.data.records.length, 1);
3291 assert.equal(restartedStatus.payload.data.records[0].view, "persisted_only");
3292 assert.equal(restartedStatus.payload.data.records[0].status, "stale");
3293 assert.equal(restartedStatus.payload.data.records[0].live, null);
3294 assert.equal(restartedStatus.payload.data.records[0].persisted.status, "stale");
3295 assert.equal(restartedStatus.payload.data.summary.persisted_only_records, 1);
3296 assert.equal(
3297 restartedStatus.payload.data.records[0].persisted.credential_fingerprint,
3298 "fp-claude-persist"
3299 );
3300 assert.deepEqual(
3301 restartedStatus.payload.data.records[0].persisted.endpoints,
3302 [
3303 "GET /api/organizations",
3304 "POST /api/organizations/{id}/chat_conversations/{id}/completion"
3305 ]
3306 );
3307 assertNoSecretLeak(restartedStatus.text, ["persist-secret", "csrf-persist-secret"]);
3308 } finally {
3309 client?.queue.stop();
3310
3311 if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
3312 client.socket.close(1000, "done");
3313 }
3314
3315 if (runtime != null) {
3316 await runtime.stop();
3317 }
3318
3319 rmSync(stateDir, {
3320 force: true,
3321 recursive: true
3322 });
3323 }
3324});
3325
3326test("browser control e2e smoke shows browser login state aging from fresh to stale to lost", async () => {
3327 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-aging-smoke-"));
3328 let nowSeconds = 100;
3329 const runtime = new ConductorRuntime(
3330 {
3331 nodeId: "mini-main",
3332 host: "mini",
3333 role: "primary",
3334 controlApiBase: "https://conductor.example.test",
3335 localApiBase: "http://127.0.0.1:0",
3336 sharedToken: "replace-me",
3337 paths: {
3338 runsDir: "/tmp/runs",
3339 stateDir
3340 }
3341 },
3342 {
3343 autoStartLoops: false,
3344 now: () => nowSeconds
3345 }
3346 );
3347
3348 let client = null;
3349
3350 try {
3351 const snapshot = await runtime.start();
3352 const baseUrl = snapshot.controlApi.localApiBase;
3353 client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-aging-smoke");
3354
3355 client.socket.send(
3356 JSON.stringify({
3357 type: "credentials",
3358 platform: "claude",
3359 account: "aging@example.com",
3360 credential_fingerprint: "fp-aging",
3361 freshness: "fresh",
3362 captured_at: 100_000,
3363 last_seen_at: 100_000,
3364 headers: {
3365 cookie: "session=aging-secret"
3366 }
3367 })
3368 );
3369 await client.queue.next(
3370 (message) => message.type === "state_snapshot" && message.reason === "credentials"
3371 );
3372
3373 const freshStatus = await fetchJson(
3374 `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
3375 );
3376 assert.equal(freshStatus.response.status, 200);
3377 assert.equal(freshStatus.payload.data.records[0].status, "fresh");
3378 assert.equal(freshStatus.payload.data.summary.status_counts.fresh, 1);
3379 assertNoSecretLeak(freshStatus.text, ["aging-secret"]);
3380
3381 nowSeconds = 160;
3382 await new Promise((resolve) => setTimeout(resolve, 2_200));
3383
3384 const staleStatus = await waitForCondition(async () => {
3385 const result = await fetchJson(
3386 `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
3387 );
3388 assert.equal(result.payload.data.records[0].status, "stale");
3389 assert.equal(result.payload.data.summary.status_counts.stale, 1);
3390 return result;
3391 }, 3_000, 100);
3392 assert.equal(staleStatus.payload.data.records[0].view, "active_and_persisted");
3393
3394 nowSeconds = 260;
3395 await new Promise((resolve) => setTimeout(resolve, 2_200));
3396
3397 const lostStatus = await waitForCondition(async () => {
3398 const result = await fetchJson(
3399 `${baseUrl}/v1/browser?platform=claude&account=aging%40example.com`
3400 );
3401 assert.equal(result.payload.data.records[0].status, "lost");
3402 assert.equal(result.payload.data.summary.status_counts.lost, 1);
3403 return result;
3404 }, 3_000, 100);
3405 assert.equal(lostStatus.payload.data.records[0].persisted.status, "lost");
3406 assertNoSecretLeak(lostStatus.text, ["aging-secret"]);
3407 } finally {
3408 client?.queue.stop();
3409
3410 if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
3411 client.socket.close(1000, "done");
3412 }
3413
3414 await runtime.stop();
3415 rmSync(stateDir, {
3416 force: true,
3417 recursive: true
3418 });
3419 }
3420});