baa-conductor

git clone 

baa-conductor / tests / browser
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});