Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/browser/bridge-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ describe('waitForBridgeReady', () => {
expect(fetchHealth).toHaveBeenCalledTimes(3);
});

it('forwards a preferred profile on every health poll', async () => {
const fetchHealth: HealthFetcher = vi.fn(async () => readyHealth());

await waitForBridgeReady(fetchHealth, {
timeoutMs: 10_000,
preferredContextId: 'personal profile',
intervalMs: 1,
});

expect(fetchHealth).toHaveBeenCalledWith({ contextId: undefined, preferredContextId: 'personal profile' });
});

it('returns the last observed non-ready health when the deadline expires', async () => {
const fetchHealth: HealthFetcher = vi.fn(async () => notReadyHealth('profile-disconnected'));

Expand Down
8 changes: 4 additions & 4 deletions src/browser/bridge-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ import type { DaemonHealth } from './daemon-transport.js';

export type { DaemonHealth };

export type HealthFetcher = (opts?: { timeout?: number; contextId?: string }) => Promise<DaemonHealth>;
export type HealthFetcher = (opts?: { timeout?: number; contextId?: string; preferredContextId?: string }) => Promise<DaemonHealth>;

const DEFAULT_POLL_INTERVAL_MS = 200;

export async function waitForBridgeReady(
fetchHealth: HealthFetcher,
opts: { timeoutMs: number; contextId?: string; intervalMs?: number },
opts: { timeoutMs: number; contextId?: string; preferredContextId?: string; intervalMs?: number },
): Promise<DaemonHealth> {
const interval = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
let health = await fetchHealth({ contextId: opts.contextId });
let health = await fetchHealth({ contextId: opts.contextId, preferredContextId: opts.preferredContextId });
if (health.state === 'ready') return health;

const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
await new Promise<void>((resolve) => setTimeout(resolve, interval));
health = await fetchHealth({ contextId: opts.contextId });
health = await fetchHealth({ contextId: opts.contextId, preferredContextId: opts.preferredContextId });
if (health.state === 'ready') return health;
}
return health;
Expand Down
5 changes: 3 additions & 2 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class BrowserBridge implements IBrowserFactory {
const routing = opts.contextId || opts.preferredContextId
? { contextId: opts.contextId, preferredContextId: opts.preferredContextId }
: profileRouteParams(resolveProfileSelection());
await this._ensureDaemon(opts.timeout, routing.contextId);
await this._ensureDaemon(opts.timeout, routing.contextId, routing.preferredContextId);
if (!opts.session?.trim()) throw new Error('Browser session is required');
this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
this._state = 'connected';
Expand All @@ -61,10 +61,11 @@ export class BrowserBridge implements IBrowserFactory {
this._state = 'closed';
}

private async _ensureDaemon(timeoutSeconds?: number, contextId?: string): Promise<void> {
private async _ensureDaemon(timeoutSeconds?: number, contextId?: string, preferredContextId?: string): Promise<void> {
const result = await ensureBrowserBridgeReady({
timeoutSeconds: timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000),
contextId,
preferredContextId,
});
this._daemonProc = result.spawnedProcess;
}
Expand Down
6 changes: 3 additions & 3 deletions src/browser/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe('daemon-client', () => {
await expect(getDaemonHealth()).resolves.toEqual({ state: 'profile-required', status });
});

it('fetchDaemonStatus includes contextId in the status query', async () => {
it('fetchDaemonStatus includes profile routing in the status query', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
Expand All @@ -151,9 +151,9 @@ describe('daemon-client', () => {
}),
} as Response);

await fetchDaemonStatus({ contextId: 'work' });
await fetchDaemonStatus({ contextId: 'work', preferredContextId: 'personal profile' });

expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/);
expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work&preferredContextId=personal\+profile$/);
});

it('rejects OPENCLI_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => {
Expand Down
7 changes: 4 additions & 3 deletions src/browser/daemon-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,15 @@ export async function restartDaemon(opts: { stopTimeoutMs?: number; startTimeout
}

export async function ensureBrowserBridgeReady(
opts: { timeoutSeconds?: number; contextId?: string; verbose?: boolean } = {},
opts: { timeoutSeconds?: number; contextId?: string; preferredContextId?: string; verbose?: boolean } = {},
): Promise<EnsureBrowserBridgeReadyResult> {
const timeoutSeconds = opts.timeoutSeconds && opts.timeoutSeconds > 0 ? opts.timeoutSeconds : 10;
const timeoutMs = timeoutSeconds * 1000;
const verbose = opts.verbose ?? true;
const contextId = opts.contextId;
const preferredContextId = opts.preferredContextId;

const health = await getDaemonHealth({ contextId });
const health = await getDaemonHealth({ contextId, preferredContextId });
const daemonVersion = health.status?.daemonVersion;
const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION);
let staleDaemonReplaced = false;
Expand Down Expand Up @@ -158,7 +159,7 @@ export async function ensureBrowserBridgeReady(
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
}

const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId });
const finalHealth = await waitForBridgeReady(getDaemonHealth, { timeoutMs, contextId, preferredContextId });
if (finalHealth.state === 'ready') return { health: finalHealth, spawnedProcess };
throw browserConnectErrorFromHealth(finalHealth, contextId);
}
Expand Down
9 changes: 6 additions & 3 deletions src/browser/daemon-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ export async function requestDaemon(pathname: string, init?: RequestInit & { tim
}
}

export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string }): Promise<DaemonStatus | null> {
export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string; preferredContextId?: string }): Promise<DaemonStatus | null> {
try {
const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : '';
const searchParams = new URLSearchParams();
if (opts?.contextId) searchParams.set('contextId', opts.contextId);
if (opts?.preferredContextId) searchParams.set('preferredContextId', opts.preferredContextId);
const params = searchParams.size ? `?${searchParams}` : '';
const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 });
if (!res.ok) return null;
return await res.json() as DaemonStatus;
Expand All @@ -78,7 +81,7 @@ export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: s
}
}

export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string }): Promise<DaemonHealth> {
export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string; preferredContextId?: string }): Promise<DaemonHealth> {
const status = await fetchDaemonStatus(opts);
if (!status) return { state: 'stopped', status: null };
if (status.profileRequired) return { state: 'profile-required', status };
Expand Down
5 changes: 3 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
const mem = process.memoryUsage();
const params = new URL(url, `http://localhost:${PORT}`).searchParams;
const requestedContextId = params.get('contextId')?.trim() || undefined;
const route = resolveExtensionConnection(requestedContextId);
const preferredContextId = params.get('preferredContextId')?.trim() || undefined;
const route = resolveExtensionConnection(requestedContextId, preferredContextId);
const profiles = activeProfiles().map((profile) => ({
contextId: profile.contextId,
extensionConnected: true,
Expand All @@ -311,7 +312,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
extensionConnected: !!route.connection,
extensionVersion: route.connection?.extensionVersion ?? undefined,
extensionCompatRange: route.connection?.extensionCompatRange ?? undefined,
contextId: route.connection?.contextId ?? requestedContextId,
contextId: route.connection?.contextId ?? requestedContextId ?? preferredContextId,
profileRequired: route.errorCode === 'profile_required',
profileDisconnected: route.errorCode === 'profile_disconnected',
profiles,
Expand Down
2 changes: 2 additions & 0 deletions src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ describe('doctor report rendering', () => {

const report = await runBrowserDoctor();

expect(mockGetDaemonHealth).toHaveBeenCalledWith({ preferredContextId: 'zvypsyje' });

expect(report.issues).toEqual(expect.arrayContaining([
expect.stringContaining('Default browser profile is stale: work (zvypsyje)'),
]));
Expand Down
4 changes: 2 additions & 2 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getErrorMessage } from './errors.js';
import { getRuntimeLabel } from './runtime-detect.js';
import { getCachedLatestExtensionVersion } from './update-check.js';
import type { BrowserProfileStatus } from './browser/daemon-transport.js';
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
import { aliasForContextId, loadProfileConfig, profileRouteParams, resolveProfileSelection } from './browser/profile.js';
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js';

Expand Down Expand Up @@ -112,7 +112,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
const connectivity = await checkConnectivity();

// Single status read *after* connectivity side-effects settle.
const health = await getDaemonHealth();
const health = await getDaemonHealth(profileRouteParams(resolveProfileSelection()));
const daemonRunning = health.state !== 'stopped';
const extensionConnected = health.state === 'ready';
const daemonFlaky = connectivity.ok && !daemonRunning;
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/daemon-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,23 @@ describe('daemon transport contracts (real daemon)', () => {
}
});

it('uses preferredContextId when reporting daemon status', async () => {
if (guard()) return;
const ext = new FakeExtension();
await ext.connect('ctx-status');
try {
const res = await fetch(`${BASE}/status?preferredContextId=ctx-status`, { headers: HEADERS });
const status = await res.json();

expect(res.ok).toBe(true);
expect(status.extensionConnected).toBe(true);
expect(status.contextId).toBe('ctx-status');
expect(status.profileRequired).toBe(false);
} finally {
ext.close();
}
});

it('flushes a structured daemon_shutting_down 503 to in-flight dispatched commands on shutdown', async () => {
if (guard()) return;
const ext = new FakeExtension();
Expand Down