-
Notifications
You must be signed in to change notification settings - Fork 80
fix(chrome-extension): popup pairing reply + relay-aware host_browser result POST #24194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
noanflaherty
merged 2 commits into
noanflaherty/host-browser-proxy-phase-2
from
phase-2-fixes/pr-3-worker-p1-fixes
Apr 8, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
266 changes: 266 additions & 0 deletions
266
clients/chrome-extension/background/__tests__/worker-host-browser-result.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| /** | ||
| * Tests for the relay-aware host_browser result poster. | ||
| * | ||
| * Drives `postHostBrowserResult` against a fake `fetch` and a fake | ||
| * `RelayConnection` so we can exercise both transport branches without | ||
| * standing up a real socket or local daemon. Covers: | ||
| * | ||
| * - self-hosted mode: POSTs to `${baseUrl}/v1/host-browser-result` | ||
| * with `Authorization: Bearer <token>` and the JSON-serialised | ||
| * result envelope as the body. | ||
| * - cloud mode with an OPEN connection: sends a JSON-stringified | ||
| * `host_browser_result` frame via `connection.send` and never | ||
| * touches `fetch`. | ||
| * - cloud mode with a closed or null connection: logs a warning, | ||
| * never touches `fetch`, and never throws. | ||
| * | ||
| * The function lives in `relay-connection.ts` (rather than `worker.ts`) | ||
| * so the test can import it directly without dragging in the chrome | ||
| * service worker module surface. | ||
| */ | ||
|
|
||
| import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; | ||
|
|
||
| import { | ||
| postHostBrowserResult, | ||
| type RelayConnectionLike, | ||
| type RelayMode, | ||
| } from '../relay-connection.js'; | ||
| import type { HostBrowserResultEnvelope } from '../host-browser-dispatcher.js'; | ||
|
|
||
| // ── Fake transports ───────────────────────────────────────────────── | ||
|
|
||
| interface FakeFetchCall { | ||
| input: string; | ||
| init?: RequestInit; | ||
| } | ||
|
|
||
| interface FakeFetchHandle { | ||
| calls: FakeFetchCall[]; | ||
| /** Sets the response returned by the next fetch call. */ | ||
| setNextResponse(resp: Response): void; | ||
| restore(): void; | ||
| } | ||
|
|
||
| function installFakeFetch(): FakeFetchHandle { | ||
| const calls: FakeFetchCall[] = []; | ||
| let nextResponse: Response = new Response(null, { status: 200 }); | ||
| const original = (globalThis as { fetch?: typeof fetch }).fetch; | ||
| (globalThis as { fetch: typeof fetch }).fetch = (async ( | ||
| input: RequestInfo | URL, | ||
| init?: RequestInit, | ||
| ) => { | ||
| calls.push({ input: String(input), init }); | ||
| return nextResponse; | ||
| }) as typeof fetch; | ||
| return { | ||
| calls, | ||
| setNextResponse(resp) { | ||
| nextResponse = resp; | ||
| }, | ||
| restore() { | ||
| if (original) { | ||
| (globalThis as { fetch: typeof fetch }).fetch = original; | ||
| } else { | ||
| delete (globalThis as { fetch?: typeof fetch }).fetch; | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| interface FakeConnection extends RelayConnectionLike { | ||
| sent: string[]; | ||
| /** Toggle whether `isOpen()` returns true or false. */ | ||
| open: boolean; | ||
| } | ||
|
|
||
| function makeFakeConnection(open: boolean): FakeConnection { | ||
| const sent: string[] = []; | ||
| return { | ||
| sent, | ||
| open, | ||
| isOpen() { | ||
| return this.open; | ||
| }, | ||
| send(data) { | ||
| sent.push(data); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| interface ConsoleSpy { | ||
| warnings: unknown[][]; | ||
| restore(): void; | ||
| } | ||
|
|
||
| function spyConsoleWarn(): ConsoleSpy { | ||
| const warnings: unknown[][] = []; | ||
| const original = console.warn; | ||
| console.warn = (...args: unknown[]) => { | ||
| warnings.push(args); | ||
| }; | ||
| return { | ||
| warnings, | ||
| restore() { | ||
| console.warn = original; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| // ── Fixtures ──────────────────────────────────────────────────────── | ||
|
|
||
| const exampleResult: HostBrowserResultEnvelope = { | ||
| requestId: 'req-abc', | ||
| content: '{"frameId":"42"}', | ||
| isError: false, | ||
| }; | ||
|
|
||
| let fetchHandle: FakeFetchHandle; | ||
| let consoleSpy: ConsoleSpy; | ||
|
|
||
| beforeEach(() => { | ||
| fetchHandle = installFakeFetch(); | ||
| consoleSpy = spyConsoleWarn(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fetchHandle.restore(); | ||
| consoleSpy.restore(); | ||
| }); | ||
|
|
||
| // ── Self-hosted mode ──────────────────────────────────────────────── | ||
|
|
||
| describe('postHostBrowserResult — self-hosted mode', () => { | ||
| test('POSTs to ${baseUrl}/v1/host-browser-result with bearer auth', async () => { | ||
| const mode: RelayMode = { | ||
| kind: 'self-hosted', | ||
| baseUrl: 'http://127.0.0.1:9999', | ||
| token: 'tok-1', | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, null, exampleResult); | ||
|
|
||
| expect(fetchHandle.calls.length).toBe(1); | ||
| const call = fetchHandle.calls[0]; | ||
| expect(call.input).toBe('http://127.0.0.1:9999/v1/host-browser-result'); | ||
| expect(call.init?.method).toBe('POST'); | ||
| const headers = call.init?.headers as Record<string, string> | undefined; | ||
| expect(headers?.authorization).toBe('Bearer tok-1'); | ||
| expect(headers?.['content-type']).toBe('application/json'); | ||
| expect(call.init?.body).toBe(JSON.stringify(exampleResult)); | ||
| }); | ||
|
|
||
| test('omits the authorization header when no token is configured', async () => { | ||
| const mode: RelayMode = { | ||
| kind: 'self-hosted', | ||
| baseUrl: 'http://127.0.0.1:9999', | ||
| token: null, | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, null, exampleResult); | ||
|
|
||
| expect(fetchHandle.calls.length).toBe(1); | ||
| const headers = fetchHandle.calls[0].init?.headers as | ||
| | Record<string, string> | ||
| | undefined; | ||
| expect(headers?.authorization).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('strips a trailing slash from the base URL', async () => { | ||
| const mode: RelayMode = { | ||
| kind: 'self-hosted', | ||
| baseUrl: 'http://127.0.0.1:9999/', | ||
| token: 'tok-1', | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, null, exampleResult); | ||
|
|
||
| expect(fetchHandle.calls[0].input).toBe( | ||
| 'http://127.0.0.1:9999/v1/host-browser-result', | ||
| ); | ||
| }); | ||
|
|
||
| test('logs a warning when the daemon returns a non-2xx status', async () => { | ||
| fetchHandle.setNextResponse(new Response(null, { status: 503 })); | ||
| const mode: RelayMode = { | ||
| kind: 'self-hosted', | ||
| baseUrl: 'http://127.0.0.1:9999', | ||
| token: 'tok-1', | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, null, exampleResult); | ||
|
|
||
| expect(consoleSpy.warnings.length).toBeGreaterThanOrEqual(1); | ||
| const flat = consoleSpy.warnings.flat().join(' '); | ||
| expect(flat).toContain('503'); | ||
| }); | ||
|
|
||
| test('ignores the supplied connection in self-hosted mode', async () => { | ||
| const conn = makeFakeConnection(true); | ||
| const mode: RelayMode = { | ||
| kind: 'self-hosted', | ||
| baseUrl: 'http://127.0.0.1:9999', | ||
| token: 'tok-1', | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, conn, exampleResult); | ||
|
|
||
| expect(fetchHandle.calls.length).toBe(1); | ||
| expect(conn.sent).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| // ── Cloud mode ────────────────────────────────────────────────────── | ||
|
|
||
| describe('postHostBrowserResult — cloud mode', () => { | ||
| test('sends a host_browser_result frame over an open connection and skips fetch', async () => { | ||
| const conn = makeFakeConnection(true); | ||
| const mode: RelayMode = { | ||
| kind: 'cloud', | ||
| baseUrl: 'https://api.vellum.ai', | ||
| token: 'cloud-token', | ||
| }; | ||
|
|
||
| await postHostBrowserResult(mode, conn, exampleResult); | ||
|
|
||
| expect(fetchHandle.calls).toEqual([]); | ||
| expect(conn.sent.length).toBe(1); | ||
| const parsed = JSON.parse(conn.sent[0]) as Record<string, unknown>; | ||
| expect(parsed.type).toBe('host_browser_result'); | ||
| expect(parsed.requestId).toBe(exampleResult.requestId); | ||
| expect(parsed.content).toBe(exampleResult.content); | ||
| expect(parsed.isError).toBe(exampleResult.isError); | ||
| }); | ||
|
|
||
| test('warns and no-ops when the connection is not open', async () => { | ||
| const conn = makeFakeConnection(false); | ||
| const mode: RelayMode = { | ||
| kind: 'cloud', | ||
| baseUrl: 'https://api.vellum.ai', | ||
| token: 'cloud-token', | ||
| }; | ||
|
|
||
| const returned = await postHostBrowserResult(mode, conn, exampleResult); | ||
| expect(returned).toBeUndefined(); | ||
|
|
||
| expect(fetchHandle.calls).toEqual([]); | ||
| expect(conn.sent).toEqual([]); | ||
| const flat = consoleSpy.warnings.flat().join(' '); | ||
| expect(flat).toContain('cloud relay not connected'); | ||
| }); | ||
|
|
||
| test('warns and no-ops when the connection is null', async () => { | ||
| const mode: RelayMode = { | ||
| kind: 'cloud', | ||
| baseUrl: 'https://api.vellum.ai', | ||
| token: 'cloud-token', | ||
| }; | ||
|
|
||
| const returned = await postHostBrowserResult(mode, null, exampleResult); | ||
| expect(returned).toBeUndefined(); | ||
|
|
||
| expect(fetchHandle.calls).toEqual([]); | ||
| const flat = consoleSpy.warnings.flat().join(' '); | ||
| expect(flat).toContain('cloud relay not connected'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 Cloud mode result posting silently drops results when connection is temporarily down
In cloud mode, if the WebSocket is temporarily disconnected (e.g., during reconnection backoff),
postHostBrowserResultatrelay-connection.ts:305-309logs a warning and returns without delivering the result. Unlike self-hosted mode which has an HTTP fallback, there's no retry or queuing mechanism for cloud mode. This means CDP results generated during a brief reconnection window are permanently lost. The docstring explicitly acknowledges this as intentional for Phase 2 (the cloud CDP path is feature-flagged off), but it's worth noting for Phase 3 when the cloud path goes live — a queue-and-retry or at minimum a more prominent error propagation may be needed.Was this helpful? React with 👍 or 👎 to provide feedback.