Keep connected provider details visible while refreshes are in flight, restore reusable provider status UI, and separate fast startup summaries from heavier provider hydration. Replace the fixed 30s startup wait with an idle-aware scheduler with a 30s safety cap and cover the Electron timer binding crash.
767 lines
24 KiB
TypeScript
767 lines
24 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
|
import type { CliInstallationStatus } from '@shared/types';
|
|
|
|
interface StoreState {
|
|
fetchPluginCatalog: ReturnType<typeof vi.fn>;
|
|
bootstrapCliStatus: ReturnType<typeof vi.fn>;
|
|
fetchCliStatus: ReturnType<typeof vi.fn>;
|
|
fetchApiKeys: ReturnType<typeof vi.fn>;
|
|
fetchSkillsCatalog: ReturnType<typeof vi.fn>;
|
|
mcpBrowse: ReturnType<typeof vi.fn>;
|
|
mcpFetchInstalled: ReturnType<typeof vi.fn>;
|
|
apiKeysLoading: boolean;
|
|
pluginCatalogLoading: boolean;
|
|
mcpBrowseLoading: boolean;
|
|
skillsLoading: boolean;
|
|
cliStatus: CliInstallationStatus | null;
|
|
cliStatusLoading: boolean;
|
|
cliProviderStatusLoading: Record<string, boolean>;
|
|
appConfig: {
|
|
general: {
|
|
multimodelEnabled: boolean;
|
|
};
|
|
};
|
|
openDashboard: ReturnType<typeof vi.fn>;
|
|
sessions: { isOngoing: boolean }[];
|
|
projects: unknown[];
|
|
repositoryGroups: unknown[];
|
|
}
|
|
|
|
const storeState = {} as StoreState;
|
|
const codexAccountHookState = {
|
|
snapshot: null as CodexAccountSnapshotDto | null,
|
|
loading: false,
|
|
error: null as string | null,
|
|
refresh: vi.fn(() => Promise.resolve(undefined)),
|
|
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
|
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
|
logout: vi.fn(() => Promise.resolve(true)),
|
|
};
|
|
const pluginsPanelSpy = vi.fn();
|
|
const mcpServersPanelSpy = vi.fn();
|
|
const customMcpDialogSpy = vi.fn();
|
|
const useCodexAccountSnapshotSpy = vi.fn(
|
|
(_options: { enabled: boolean; includeRateLimits?: boolean; initialRefreshDelayMs?: number }) =>
|
|
codexAccountHookState
|
|
);
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
|
}));
|
|
|
|
vi.mock('zustand/react/shallow', () => ({
|
|
useShallow: <T>(selector: T) => selector,
|
|
}));
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
plugins: {},
|
|
mcpRegistry: {},
|
|
skills: {},
|
|
},
|
|
isElectronMode: () => true,
|
|
}));
|
|
|
|
vi.mock('@features/codex-account/renderer', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@features/codex-account/renderer')>();
|
|
return {
|
|
...actual,
|
|
useCodexAccountSnapshot: (options: {
|
|
enabled: boolean;
|
|
includeRateLimits?: boolean;
|
|
initialRefreshDelayMs?: number;
|
|
}) => useCodexAccountSnapshotSpy(options),
|
|
};
|
|
});
|
|
|
|
vi.mock('@renderer/contexts/useTabUIContext', () => ({
|
|
useTabIdOptional: () => undefined,
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useExtensionsTabState', () => ({
|
|
useExtensionsTabState: () => ({
|
|
activeSubTab: 'plugins',
|
|
setActiveSubTab: vi.fn(),
|
|
pluginFilters: {
|
|
search: '',
|
|
categories: [],
|
|
capabilities: [],
|
|
installedOnly: false,
|
|
},
|
|
pluginSort: { field: 'popularity', order: 'desc' },
|
|
setPluginSort: vi.fn(),
|
|
selectedPluginId: null,
|
|
setSelectedPluginId: vi.fn(),
|
|
updatePluginSearch: vi.fn(),
|
|
toggleCategory: vi.fn(),
|
|
toggleCapability: vi.fn(),
|
|
toggleInstalledOnly: vi.fn(),
|
|
clearFilters: vi.fn(),
|
|
hasActiveFilters: false,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
skillsSearchQuery: '',
|
|
setSkillsSearchQuery: vi.fn(),
|
|
skillsInstalledOnly: false,
|
|
skillsSort: 'name-asc',
|
|
setSkillsSort: vi.fn(),
|
|
selectedSkillId: null,
|
|
setSelectedSkillId: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/utils/projectLookup', () => ({
|
|
resolveProjectPathById: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
|
ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
|
|
React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/badge', () => ({
|
|
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/button', () => ({
|
|
Button: ({
|
|
children,
|
|
onClick,
|
|
disabled,
|
|
}: React.PropsWithChildren<{ onClick?: () => void; disabled?: boolean }>) =>
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
disabled,
|
|
onClick,
|
|
},
|
|
children
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/tabs', () => ({
|
|
Tabs: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
|
TabsList: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
|
TabsContent: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement('div', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/tooltip', () => ({
|
|
TooltipProvider: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
Tooltip: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
TooltipContent: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement('span', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/ExtensionsSubTabTrigger', () => ({
|
|
ExtensionsSubTabTrigger: ({ label }: { label: string }) =>
|
|
React.createElement('button', { type: 'button' }, label),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/plugins/PluginsPanel', () => ({
|
|
PluginsPanel: (props: unknown) => {
|
|
pluginsPanelSpy(props);
|
|
return React.createElement('div', null, 'plugins-panel');
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/mcp/McpServersPanel', () => ({
|
|
McpServersPanel: (props: unknown) => {
|
|
mcpServersPanelSpy(props);
|
|
return React.createElement('div', null, 'mcp-panel');
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/skills/SkillsPanel', () => ({
|
|
SkillsPanel: () => React.createElement('div', null, 'skills-panel'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/apikeys/ApiKeysPanel', () => ({
|
|
ApiKeysPanel: () => React.createElement('div', null, 'apikeys-panel'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/extensions/mcp/CustomMcpServerDialog', () => ({
|
|
CustomMcpServerDialog: (props: unknown) => {
|
|
customMcpDialogSpy(props);
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock('lucide-react', () => {
|
|
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
|
return {
|
|
AlertTriangle: Icon,
|
|
BookOpen: Icon,
|
|
Info: Icon,
|
|
Key: Icon,
|
|
Loader2: Icon,
|
|
Plus: Icon,
|
|
Puzzle: Icon,
|
|
RefreshCw: Icon,
|
|
Server: Icon,
|
|
};
|
|
});
|
|
|
|
import { ExtensionStoreView } from '@renderer/components/extensions/ExtensionStoreView';
|
|
|
|
function createLoadingMultimodelStatus(): CliInstallationStatus {
|
|
return {
|
|
flavor: 'agent_teams_orchestrator',
|
|
displayName: 'Multimodel runtime',
|
|
supportsSelfUpdate: false,
|
|
showVersionDetails: false,
|
|
showBinaryPath: false,
|
|
installed: true,
|
|
installedVersion: null,
|
|
binaryPath: '/usr/local/bin/agent-teams',
|
|
launchError: null,
|
|
latestVersion: null,
|
|
updateAvailable: false,
|
|
authLoggedIn: false,
|
|
authStatusChecking: true,
|
|
authMethod: null,
|
|
providers: [
|
|
{
|
|
providerId: 'anthropic',
|
|
displayName: 'Anthropic',
|
|
supported: false,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: 'Checking...',
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: {
|
|
teamLaunch: false,
|
|
oneShot: false,
|
|
extensions: {
|
|
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
|
},
|
|
},
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: null,
|
|
connection: null,
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
displayName: 'Codex',
|
|
supported: false,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: 'Checking...',
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: {
|
|
teamLaunch: false,
|
|
oneShot: false,
|
|
extensions: {
|
|
plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null },
|
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
|
},
|
|
},
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: null,
|
|
connection: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('ExtensionStoreView provider loading placeholders', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
pluginsPanelSpy.mockReset();
|
|
mcpServersPanelSpy.mockReset();
|
|
customMcpDialogSpy.mockReset();
|
|
useCodexAccountSnapshotSpy.mockClear();
|
|
codexAccountHookState.snapshot = null;
|
|
codexAccountHookState.loading = false;
|
|
codexAccountHookState.error = null;
|
|
codexAccountHookState.refresh.mockReset().mockResolvedValue(undefined);
|
|
codexAccountHookState.startChatgptLogin.mockReset().mockResolvedValue(true);
|
|
codexAccountHookState.cancelChatgptLogin.mockReset().mockResolvedValue(true);
|
|
codexAccountHookState.logout.mockReset().mockResolvedValue(true);
|
|
storeState.fetchPluginCatalog = vi.fn().mockResolvedValue(undefined);
|
|
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
|
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
|
storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined);
|
|
storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined);
|
|
storeState.mcpBrowse = vi.fn().mockResolvedValue(undefined);
|
|
storeState.mcpFetchInstalled = vi.fn().mockResolvedValue(undefined);
|
|
storeState.apiKeysLoading = false;
|
|
storeState.pluginCatalogLoading = false;
|
|
storeState.mcpBrowseLoading = false;
|
|
storeState.skillsLoading = false;
|
|
storeState.cliStatus = createLoadingMultimodelStatus();
|
|
storeState.cliStatusLoading = true;
|
|
storeState.cliProviderStatusLoading = {
|
|
anthropic: true,
|
|
codex: true,
|
|
};
|
|
storeState.appConfig = {
|
|
general: {
|
|
multimodelEnabled: true,
|
|
},
|
|
};
|
|
storeState.openDashboard = vi.fn();
|
|
storeState.sessions = [];
|
|
storeState.projects = [];
|
|
storeState.repositoryGroups = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('shows multimodel provider skeleton cards while provider status is still loading', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.bootstrapCliStatus).not.toHaveBeenCalled();
|
|
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
|
|
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
|
|
|
|
expect(host.textContent).toContain('Multimodel runtime capabilities');
|
|
expect(host.textContent).toContain('Anthropic');
|
|
expect(host.textContent).toContain('Codex');
|
|
expect(host.textContent).toContain('Checking provider status...');
|
|
expect(host.textContent).toContain('Loading...');
|
|
expect(host.textContent).not.toContain('Checking extensions runtime availability');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not defer Codex account refresh again after the lazy Extensions tab mounts', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const lastOptions = useCodexAccountSnapshotSpy.mock.calls.at(-1)?.[0] as
|
|
| { enabled?: boolean; includeRateLimits?: boolean; initialRefreshDelayMs?: number }
|
|
| undefined;
|
|
expect(lastOptions).toEqual(
|
|
expect.objectContaining({
|
|
enabled: true,
|
|
includeRateLimits: true,
|
|
})
|
|
);
|
|
expect(lastOptions?.initialRefreshDelayMs).toBeUndefined();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('falls back to legacy refresh when multimodel is disabled', async () => {
|
|
storeState.appConfig = {
|
|
general: {
|
|
multimodelEnabled: false,
|
|
},
|
|
};
|
|
storeState.cliStatusLoading = false;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.fetchCliStatus).toHaveBeenCalledTimes(1);
|
|
expect(storeState.bootstrapCliStatus).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps provider placeholders visible when bootstrap data still says Checking...', async () => {
|
|
storeState.cliStatusLoading = false;
|
|
storeState.cliProviderStatusLoading = {};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Checking provider status...');
|
|
expect(host.textContent).toContain('Loading...');
|
|
expect(host.textContent).not.toContain('Plugins: unsupported');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows OpenCode plugins as unsupported in multimodel capability cards', async () => {
|
|
storeState.cliStatusLoading = false;
|
|
storeState.cliProviderStatusLoading = {};
|
|
const baseProvider = createLoadingMultimodelStatus().providers[0];
|
|
storeState.cliStatus = {
|
|
...createLoadingMultimodelStatus(),
|
|
authLoggedIn: true,
|
|
authStatusChecking: false,
|
|
providers: [
|
|
{
|
|
...baseProvider,
|
|
providerId: 'opencode',
|
|
displayName: 'OpenCode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
statusMessage: 'OpenCode CLI',
|
|
canLoginFromUi: false,
|
|
capabilities: {
|
|
teamLaunch: false,
|
|
oneShot: false,
|
|
extensions: {
|
|
plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null },
|
|
mcp: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
|
skills: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
|
apiKeys: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
|
},
|
|
},
|
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode');
|
|
expect(host.textContent).toContain('Plugins: unsupported');
|
|
expect(host.textContent).toContain('MCP: read-only');
|
|
expect(host.textContent).not.toContain('Plugins: read-only');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('uses the live Codex account snapshot to replace stale extension-card status', async () => {
|
|
storeState.cliStatusLoading = false;
|
|
storeState.cliProviderStatusLoading = {};
|
|
codexAccountHookState.snapshot = {
|
|
preferredAuthMode: 'chatgpt',
|
|
effectiveAuthMode: 'chatgpt',
|
|
launchAllowed: true,
|
|
launchIssueMessage: null,
|
|
launchReadinessState: 'ready_chatgpt',
|
|
appServerState: 'healthy',
|
|
appServerStatusMessage: null,
|
|
managedAccount: {
|
|
type: 'chatgpt',
|
|
email: 'user@example.com',
|
|
planType: 'pro',
|
|
},
|
|
apiKey: {
|
|
available: true,
|
|
source: 'environment',
|
|
sourceLabel: 'Detected from OPENAI_API_KEY',
|
|
},
|
|
requiresOpenaiAuth: false,
|
|
login: {
|
|
status: 'idle',
|
|
error: null,
|
|
startedAt: null,
|
|
},
|
|
rateLimits: null,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
storeState.cliStatus = {
|
|
...createLoadingMultimodelStatus(),
|
|
authLoggedIn: true,
|
|
authStatusChecking: false,
|
|
providers: [createLoadingMultimodelStatus().providers[1]],
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Codex');
|
|
expect(host.textContent).toContain('ChatGPT account ready');
|
|
expect(host.textContent).not.toContain('Checking provider status...');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('uses the live Codex snapshot even while multimodel root status is still loading', async () => {
|
|
storeState.cliStatus = null;
|
|
storeState.cliStatusLoading = true;
|
|
storeState.cliProviderStatusLoading = {};
|
|
codexAccountHookState.snapshot = {
|
|
preferredAuthMode: 'chatgpt',
|
|
effectiveAuthMode: 'chatgpt',
|
|
launchAllowed: true,
|
|
launchIssueMessage: null,
|
|
launchReadinessState: 'ready_chatgpt',
|
|
appServerState: 'healthy',
|
|
appServerStatusMessage: null,
|
|
managedAccount: {
|
|
type: 'chatgpt',
|
|
email: 'user@example.com',
|
|
planType: 'pro',
|
|
},
|
|
apiKey: {
|
|
available: true,
|
|
source: 'environment',
|
|
sourceLabel: 'Detected from OPENAI_API_KEY',
|
|
},
|
|
requiresOpenaiAuth: false,
|
|
login: {
|
|
status: 'idle',
|
|
error: null,
|
|
startedAt: null,
|
|
},
|
|
rateLimits: {
|
|
limitId: 'plan-pro',
|
|
limitName: 'Pro',
|
|
primary: {
|
|
usedPercent: 5,
|
|
windowDurationMins: 300,
|
|
resetsAt: 1_762_547_200,
|
|
},
|
|
secondary: {
|
|
usedPercent: 41,
|
|
windowDurationMins: 10_080,
|
|
resetsAt: 1_762_891_200,
|
|
},
|
|
credits: {
|
|
hasCredits: false,
|
|
unlimited: false,
|
|
balance: null,
|
|
},
|
|
planType: 'pro',
|
|
},
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Codex');
|
|
expect(host.textContent).toContain('ChatGPT account ready');
|
|
expect(host.textContent).not.toContain('Checking extensions runtime availability');
|
|
expect(host.querySelector('button[disabled]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not leave the stale Codex placeholder stuck as unsupported once live snapshot truth arrives', async () => {
|
|
storeState.cliStatusLoading = false;
|
|
storeState.cliProviderStatusLoading = {};
|
|
codexAccountHookState.snapshot = {
|
|
preferredAuthMode: 'chatgpt',
|
|
effectiveAuthMode: null,
|
|
launchAllowed: false,
|
|
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
|
launchReadinessState: 'missing_auth',
|
|
appServerState: 'healthy',
|
|
appServerStatusMessage: null,
|
|
managedAccount: null,
|
|
apiKey: {
|
|
available: true,
|
|
source: 'environment',
|
|
sourceLabel: 'Detected from OPENAI_API_KEY',
|
|
},
|
|
requiresOpenaiAuth: true,
|
|
login: {
|
|
status: 'idle',
|
|
error: null,
|
|
startedAt: null,
|
|
},
|
|
rateLimits: null,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
storeState.cliStatus = {
|
|
...createLoadingMultimodelStatus(),
|
|
authLoggedIn: true,
|
|
authStatusChecking: false,
|
|
providers: [createLoadingMultimodelStatus().providers[1]],
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Codex');
|
|
expect(host.textContent).toContain('Needs setup');
|
|
expect(host.textContent).not.toContain('Unsupported');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('passes merged effective Codex status to nested extension panels and dialogs', async () => {
|
|
storeState.cliStatusLoading = true;
|
|
storeState.cliProviderStatusLoading = {};
|
|
codexAccountHookState.snapshot = {
|
|
preferredAuthMode: 'chatgpt',
|
|
effectiveAuthMode: 'chatgpt',
|
|
launchAllowed: true,
|
|
launchIssueMessage: null,
|
|
launchReadinessState: 'ready_chatgpt',
|
|
appServerState: 'healthy',
|
|
appServerStatusMessage: null,
|
|
managedAccount: {
|
|
type: 'chatgpt',
|
|
email: 'user@example.com',
|
|
planType: 'pro',
|
|
},
|
|
apiKey: {
|
|
available: false,
|
|
source: null,
|
|
sourceLabel: null,
|
|
},
|
|
requiresOpenaiAuth: false,
|
|
login: {
|
|
status: 'idle',
|
|
error: null,
|
|
startedAt: null,
|
|
},
|
|
rateLimits: null,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
storeState.cliStatus = {
|
|
...createLoadingMultimodelStatus(),
|
|
authLoggedIn: true,
|
|
authStatusChecking: false,
|
|
providers: [createLoadingMultimodelStatus().providers[1]],
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ExtensionStoreView));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const pluginsPanelProps = pluginsPanelSpy.mock.calls.at(-1)?.[0] as {
|
|
cliStatus?: CliInstallationStatus | null;
|
|
cliStatusLoading?: boolean;
|
|
};
|
|
const mcpPanelProps = mcpServersPanelSpy.mock.calls.at(-1)?.[0] as {
|
|
cliStatus?: CliInstallationStatus | null;
|
|
cliStatusLoading?: boolean;
|
|
};
|
|
const customDialogProps = customMcpDialogSpy.mock.calls.at(-1)?.[0] as {
|
|
cliStatus?: CliInstallationStatus | null;
|
|
cliStatusLoading?: boolean;
|
|
};
|
|
|
|
expect(pluginsPanelProps.cliStatusLoading).toBe(false);
|
|
expect(mcpPanelProps.cliStatusLoading).toBe(false);
|
|
expect(customDialogProps.cliStatusLoading).toBe(false);
|
|
expect(pluginsPanelProps.cliStatus?.providers[0]?.supported).toBe(true);
|
|
expect(pluginsPanelProps.cliStatus?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
|
|
expect(mcpPanelProps.cliStatus?.providers[0]?.resolvedBackendId).toBe('codex-native');
|
|
expect(
|
|
customDialogProps.cliStatus?.providers[0]?.connection?.codex?.managedAccount?.email
|
|
).toBe('user@example.com');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|