perf(startup): defer noncritical startup probes
This commit is contained in:
parent
a4861fa77d
commit
34a1b86b21
13 changed files with 353 additions and 53 deletions
|
|
@ -11,6 +11,7 @@ const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
|||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
|
||||
const CODEX_HIDDEN_REFRESH_MS = 60_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = 30_000;
|
||||
|
||||
function isDocumentVisible(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
|
|
@ -41,6 +42,7 @@ function getRefreshIntervalMs(options: {
|
|||
export function useCodexAccountSnapshot(options: {
|
||||
enabled: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
initialRefreshDelayMs?: number;
|
||||
}): {
|
||||
snapshot: CodexAccountSnapshotDto | null;
|
||||
loading: boolean;
|
||||
|
|
@ -62,6 +64,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||
|
||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||
lastUpdatedAtRef.current = Date.now();
|
||||
|
|
@ -117,38 +120,74 @@ export function useCodexAccountSnapshot(options: {
|
|||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
let active = true;
|
||||
let initialRefreshTimer: number | null = null;
|
||||
|
||||
const initialSnapshotRequest = options.includeRateLimits
|
||||
? api.refreshCodexAccountSnapshot({
|
||||
includeRateLimits: true,
|
||||
const startInitialSnapshotRequest = (): void => {
|
||||
if (!active || lastUpdatedAtRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const initialSnapshotRequest = options.includeRateLimits
|
||||
? api.refreshCodexAccountSnapshot({
|
||||
includeRateLimits: true,
|
||||
})
|
||||
: api.getCodexAccountSnapshot();
|
||||
|
||||
void initialSnapshotRequest
|
||||
.then((nextSnapshot) => {
|
||||
if (active) {
|
||||
applySnapshot(nextSnapshot);
|
||||
}
|
||||
})
|
||||
: api.getCodexAccountSnapshot();
|
||||
.catch((nextError) => {
|
||||
if (active) {
|
||||
setError(
|
||||
nextError instanceof Error ? nextError.message : 'Failed to load Codex account'
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void initialSnapshotRequest
|
||||
.then((nextSnapshot) => {
|
||||
applySnapshot(nextSnapshot);
|
||||
})
|
||||
.catch((nextError) => {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
});
|
||||
if (initialRefreshDelayMs > 0) {
|
||||
initialRefreshTimer = window.setTimeout(startInitialSnapshotRequest, initialRefreshDelayMs);
|
||||
} else {
|
||||
startInitialSnapshotRequest();
|
||||
}
|
||||
|
||||
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
|
||||
applySnapshot(nextSnapshot);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]);
|
||||
return () => {
|
||||
active = false;
|
||||
if (initialRefreshTimer) {
|
||||
window.clearTimeout(initialRefreshTimer);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, [
|
||||
applySnapshot,
|
||||
electronMode,
|
||||
initialRefreshDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode || !options.enabled || typeof document === 'undefined') {
|
||||
|
|
@ -167,6 +206,10 @@ export function useCodexAccountSnapshot(options: {
|
|||
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
||||
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
||||
|
||||
if (initialRefreshDelayMs > 0 && lastUpdatedAtRef.current === null && snapshot === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastUpdatedAtRef.current === null ||
|
||||
Date.now() - lastUpdatedAtRef.current >= staleAfterMs
|
||||
|
|
@ -182,12 +225,22 @@ export function useCodexAccountSnapshot(options: {
|
|||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [electronMode, options.enabled, options.includeRateLimits, refresh]);
|
||||
}, [
|
||||
electronMode,
|
||||
initialRefreshDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
refresh,
|
||||
snapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode || !options.enabled) {
|
||||
return;
|
||||
}
|
||||
if (initialRefreshDelayMs > 0 && snapshot === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshIntervalMs = getRefreshIntervalMs({
|
||||
loginStatus: snapshot?.login.status,
|
||||
|
|
@ -206,9 +259,11 @@ export function useCodexAccountSnapshot(options: {
|
|||
};
|
||||
}, [
|
||||
electronMode,
|
||||
initialRefreshDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
refresh,
|
||||
snapshot,
|
||||
snapshot?.login.status,
|
||||
visible,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot';
|
||||
export {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
useCodexAccountSnapshot,
|
||||
} from './hooks/useCodexAccountSnapshot';
|
||||
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
|
||||
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -354,10 +354,18 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
|
|||
return 'The current review request is still waiting for explicit review pickup.';
|
||||
}
|
||||
|
||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(options?: {
|
||||
includeShellEnv?: boolean;
|
||||
}): Promise<string | null> {
|
||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath({
|
||||
includeShellEnv: options?.includeShellEnv,
|
||||
});
|
||||
if (resolvedBinaryPath) return resolvedBinaryPath;
|
||||
|
||||
if (options?.includeShellEnv === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await openCodeRuntimeInstallerService?.getStatus();
|
||||
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
|
||||
|
|
@ -448,15 +456,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
||||
}
|
||||
};
|
||||
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
|
||||
const ensureOpenCodeRuntimeBinaryEnv = async (
|
||||
targetEnv: NodeJS.ProcessEnv,
|
||||
options: { includeShellEnv?: boolean } = {}
|
||||
): Promise<void> => {
|
||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||
targetEnv,
|
||||
bridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
|
||||
resolveOpenCodeRuntimeBinaryForBridgeEnv({ includeShellEnv: options.includeShellEnv }),
|
||||
onWarning: (message) => logger.warn(message),
|
||||
});
|
||||
};
|
||||
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
|
||||
try {
|
||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
|
|
@ -500,7 +511,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||
const nextEnv = { ...bridgeEnv };
|
||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
|
||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv, { includeShellEnv: true });
|
||||
if (!useHttpMcpBridge) {
|
||||
return nextEnv;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1030,13 +1030,17 @@ export class CliInstallerService {
|
|||
providerStatusMode: CliInstallerProviderStatusMode
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
if (providerStatusMode === 'defer') {
|
||||
diag.shellEnvMs = 0;
|
||||
} else {
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
}
|
||||
|
||||
const r = ref.current;
|
||||
const binaryResolveStartedAt = Date.now();
|
||||
|
|
|
|||
|
|
@ -212,8 +212,13 @@ async function probeFirstWorkingOpenCodeBinaryCandidate(
|
|||
return { ok: false, firstFailure: nextFirstFailure };
|
||||
}
|
||||
|
||||
interface OpenCodeRuntimeBinaryResolveOptions {
|
||||
shellEnvTimeoutMs?: number;
|
||||
includeShellEnv?: boolean;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinary(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
const seenCandidates = new Set<string>();
|
||||
let firstFailure: { binaryPath: string; error: string } | null = null;
|
||||
|
|
@ -230,6 +235,16 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
|||
}
|
||||
firstFailure = cachedProbe.firstFailure;
|
||||
|
||||
if (options.includeShellEnv === false) {
|
||||
return probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([], {
|
||||
includeFallbackPathEntries: true,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
}
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
|
|
@ -254,14 +269,14 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
|||
}
|
||||
|
||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||
return result.ok ? result.binaryPath : null;
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
return (
|
||||
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
||||
|
|
|
|||
|
|
@ -386,15 +386,6 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||
if (shouldPreferShellNodeProbe()) {
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
}
|
||||
|
||||
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
||||
if (fastProbe.ok) {
|
||||
_resolvedNodePath = fastProbe.path;
|
||||
|
|
@ -402,6 +393,9 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
if (shouldPreferShellNodeProbe()) {
|
||||
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||
}
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
|
@ -123,6 +124,7 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: false,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
});
|
||||
|
||||
const effectiveCliStatus = useMemo(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
|
@ -1358,6 +1359,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
});
|
||||
const visibleCliProviders = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
|
@ -176,6 +177,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
)
|
||||
),
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
});
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
|
||||
|
|
@ -124,9 +125,146 @@ describe('useCodexAccountSnapshot', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-loading': state.loading ? 'true' : 'false' },
|
||||
state.snapshot?.managedAccount?.email ?? 'empty'
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
|
||||
includeRateLimits: true,
|
||||
});
|
||||
expect(host.textContent).toContain('belief@example.com');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a deferred initial Codex snapshot timer on unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot());
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement('div', null, 'mounted');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'hidden' satisfies DocumentVisibilityState,
|
||||
});
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
let refreshNow!: () => Promise<void>;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
refreshNow = () => state.refresh({ includeRateLimits: true });
|
||||
|
||||
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await refreshNow();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
|
||||
vi.useFakeTimers();
|
||||
let visibilityState: DocumentVisibilityState = 'visible';
|
||||
const visibilityState: DocumentVisibilityState = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => visibilityState,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { realpathMock } = vi.hoisted(() => ({
|
||||
const { realpathMock, resolveInteractiveShellEnvBestEffortMock } = vi.hoisted(() => ({
|
||||
realpathMock: vi.fn(async (value: string) => value),
|
||||
resolveInteractiveShellEnvBestEffortMock: vi.fn(
|
||||
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock dependencies before importing service
|
||||
|
|
@ -83,6 +86,14 @@ vi.mock('@main/utils/cliAuthDiagLog', () => ({
|
|||
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
CliInstallerService,
|
||||
isVersionOlder,
|
||||
|
|
@ -148,6 +159,9 @@ describe('CliInstallerService', () => {
|
|||
vi.clearAllMocks();
|
||||
realpathMock.mockReset();
|
||||
realpathMock.mockImplementation(async (value: string) => value);
|
||||
resolveInteractiveShellEnvBestEffortMock.mockImplementation(
|
||||
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||
);
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'Claude CLI',
|
||||
|
|
@ -169,6 +183,13 @@ describe('CliInstallerService', () => {
|
|||
expect(status.installedVersion).toBeNull();
|
||||
expect(status.binaryPath).toBeNull();
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not block getStatus on diagnostic file writes', async () => {
|
||||
|
|
@ -356,6 +377,7 @@ describe('CliInstallerService', () => {
|
|||
.filter((event) => event.type === 'status');
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
expect(status.authStatusChecking).toBe(false);
|
||||
expect(status.authLoggedIn).toBe(false);
|
||||
expect(status.providers).toHaveLength(3);
|
||||
|
|
|
|||
|
|
@ -243,6 +243,36 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('resolves from fast fallback PATH without spawning shell env when shell env is disabled', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
|
||||
|
||||
await expect(
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||
).resolves.toBe(binaryPath);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||
timeout: 10_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not spawn shell env for shell-only PATH installs when shell env is disabled', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||
PATH: path.dirname(binaryPath),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||
).resolves.toBeNull();
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
|
||||
const olderBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
|
|
|
|||
|
|
@ -464,6 +464,28 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('skips strict shell env lookup when fast Node lookup succeeds from a minimal GUI PATH', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||
hoisted.execCliMock.mockResolvedValue({ stdout: '/fast/node', stderr: '' });
|
||||
|
||||
try {
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/fast/node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||
|
|
|
|||
Loading…
Reference in a new issue