Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});
83 changes: 83 additions & 0 deletions clients/chrome-extension/background/relay-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@
* relay messages — worker.ts owns the envelope dispatch (ExtensionCommand
* + host_browser_request via the PR 9 dispatcher) via the `onMessage`
* callback.
*
* This module also exports {@link postHostBrowserResult}, the relay-aware
* helper used by the host-browser dispatcher to ship CDP result envelopes
* back to the daemon. In self-hosted mode the result is POSTed to the
* local `/v1/host-browser-result` HTTP endpoint; in cloud mode it would
* round-trip back through the gateway WebSocket — see the function
* docstring for the current Phase 2 behaviour.
*/

import type { HostBrowserResultEnvelope } from './host-browser-dispatcher.js';

/** Reconnect backoff bounds mirror the legacy inline worker.ts values. */
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
Expand Down Expand Up @@ -246,3 +255,77 @@ export class RelayConnection {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
}
}

// ── host_browser result poster ─────────────────────────────────────
//
// The host-browser dispatcher needs a way to ship CDP result envelopes
// back to the daemon. The transport depends on the relay mode:
//
// - self-hosted: POST to the local daemon's
// `/v1/host-browser-result` endpoint, authenticated with the
// stored capability token.
// - cloud: send the envelope as a `host_browser_result` frame over
// the existing browser-relay WebSocket. The gateway proxies the
// frame straight through to the runtime — see
// `gateway/src/http/routes/browser-relay-websocket.ts`. (Phase 3
// will land the runtime-side handler for inbound result frames;
// today the runtime drops them, but the cloud CDP path is
// feature-flagged off in Phase 2 so this is harmless.)

/**
* Minimal subset of {@link RelayConnection} that {@link postHostBrowserResult}
* actually consumes. Used by tests to inject a fake without having to
* stand up a real WebSocket.
*/
export interface RelayConnectionLike {
isOpen(): boolean;
send(data: string): void;
}

/**
* Ship a host_browser result envelope back to the daemon.
*
* In self-hosted mode this POSTs to `${mode.baseUrl}/v1/host-browser-result`
* with `Authorization: Bearer <mode.token>`. In cloud mode it sends a
* `{ type: 'host_browser_result', ...result }` frame over the supplied
* relay connection.
*
* The cloud branch is a no-op (with a console.warn) when the connection
* is missing or not currently open. We deliberately do NOT throw — the
* dispatcher's error path catches and logs synchronously, but a thrown
* rejection here would bubble up to the service worker as an unhandled
* promise rejection.
*/
export async function postHostBrowserResult(
mode: RelayMode,
connection: RelayConnectionLike | null,
result: HostBrowserResultEnvelope,
): Promise<void> {
if (mode.kind === 'cloud') {
if (!connection || !connection.isOpen()) {
console.warn(
'[vellum-relay] host-browser-result dropped: cloud relay not connected',
);
return;
}
connection.send(JSON.stringify({ type: 'host_browser_result', ...result }));
return;
Comment on lines +304 to +312
Copy link
Copy Markdown
Contributor

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), postHostBrowserResult at relay-connection.ts:305-309 logs 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

// self-hosted: POST to the local daemon. The base URL is whatever
// `buildRelayModeConfig` resolved at connect time (usually
// `http://127.0.0.1:<relayPort>`).
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (mode.token) headers.authorization = `Bearer ${mode.token}`;
const url = `${mode.baseUrl.replace(/\/$/, '')}/v1/host-browser-result`;
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(result),
});
if (!resp.ok) {
console.warn(
`[vellum-relay] host-browser-result POST returned ${resp.status}`,
);
}
}
Loading