1610 lines
47 KiB
TypeScript
1610 lines
47 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const openDashboard = vi.fn();
|
|
const openTeamTab = vi.fn();
|
|
const fetchCliStatus = vi.fn();
|
|
const createSchedule = vi.fn();
|
|
const updateSchedule = vi.fn();
|
|
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
|
|
|
|
const storeState = {
|
|
appConfig: { general: { multimodelEnabled: true } },
|
|
cliStatus: { providers: [] },
|
|
cliStatusLoading: false,
|
|
fetchCliStatus,
|
|
createSchedule,
|
|
updateSchedule,
|
|
repositoryGroups: [],
|
|
selectedTeamName: 'team-alpha',
|
|
launchParamsByTeam: {},
|
|
teamByName: {},
|
|
openDashboard,
|
|
openTeamTab,
|
|
};
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
isElectronMode: () => true,
|
|
api: {
|
|
getCodexAccountSnapshot: vi.fn(async () => null),
|
|
refreshCodexAccountSnapshot: vi.fn(async () => null),
|
|
onCodexAccountSnapshotChanged: vi.fn(() => () => {}),
|
|
getProjects: vi.fn(async () => [
|
|
{
|
|
id: 'project-1',
|
|
path: '/tmp/project',
|
|
name: 'project',
|
|
sessions: [],
|
|
totalSessions: 0,
|
|
createdAt: 1,
|
|
},
|
|
]),
|
|
getDashboardRecentProjects: vi.fn(async () => ({ projects: [] })),
|
|
teams: {
|
|
getSavedRequest: vi.fn(async () => null),
|
|
replaceMembers: vi.fn(async () => {}),
|
|
prepareProvisioning: vi.fn(async () => ({})),
|
|
getWorktreeGitStatus: vi.fn(async (projectPath: string) => ({
|
|
projectPath,
|
|
isGitRepo: true,
|
|
hasHead: true,
|
|
canUseWorktrees: true,
|
|
})),
|
|
initializeGitRepository: vi.fn(async (projectPath: string) => ({
|
|
projectPath,
|
|
isGitRepo: true,
|
|
hasHead: false,
|
|
canUseWorktrees: false,
|
|
reason: 'missing_head',
|
|
})),
|
|
createInitialGitCommit: vi.fn(async (projectPath: string) => ({
|
|
projectPath,
|
|
isGitRepo: true,
|
|
hasHead: true,
|
|
canUseWorktrees: true,
|
|
})),
|
|
},
|
|
tmux: {
|
|
getStatus: vi.fn(() =>
|
|
Promise.resolve({
|
|
platform: 'win32',
|
|
nativeSupported: false,
|
|
checkedAt: '2026-04-25T00:00:00.000Z',
|
|
host: {
|
|
available: false,
|
|
version: null,
|
|
binaryPath: null,
|
|
error: null,
|
|
},
|
|
effective: {
|
|
available: true,
|
|
location: 'wsl',
|
|
version: '3.4',
|
|
binaryPath: '/usr/bin/tmux',
|
|
runtimeReady: true,
|
|
detail: 'tmux is ready',
|
|
},
|
|
error: null,
|
|
autoInstall: {
|
|
supported: false,
|
|
strategy: 'manual',
|
|
packageManagerLabel: null,
|
|
requiresTerminalInput: false,
|
|
requiresAdmin: false,
|
|
requiresRestart: false,
|
|
mayOpenExternalWindow: false,
|
|
reasonIfUnsupported: null,
|
|
manualHints: [],
|
|
},
|
|
wsl: null,
|
|
wslPreference: null,
|
|
})
|
|
),
|
|
onProgress: vi.fn(() => vi.fn()),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
|
}));
|
|
|
|
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
|
isTeamProvisioningActive: () => false,
|
|
selectResolvedMembersForTeamName: () => [],
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|
buildMemberDraftColorMap: () => new Map<string, string>(),
|
|
buildMemberDraftSuggestions: () => [],
|
|
buildMembersFromDrafts: (
|
|
drafts: Array<{
|
|
name: string;
|
|
roleSelection?: string;
|
|
customRole?: string;
|
|
workflow?: string;
|
|
providerId?: string;
|
|
providerBackendId?: string;
|
|
model?: string;
|
|
effort?: string;
|
|
fastMode?: string;
|
|
}>
|
|
) =>
|
|
drafts.map((draft) => ({
|
|
name: draft.name,
|
|
role: draft.customRole || undefined,
|
|
workflow: draft.workflow,
|
|
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined,
|
|
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
|
|
model: draft.model,
|
|
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
|
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
|
|
})),
|
|
clearMemberModelOverrides: (member: unknown) => member,
|
|
createMemberDraftsFromInputs: (
|
|
members: Array<{
|
|
name: string;
|
|
role?: string;
|
|
workflow?: string;
|
|
providerId?: string;
|
|
providerBackendId?: string;
|
|
model?: string;
|
|
effort?: string;
|
|
fastMode?: string;
|
|
isolation?: 'worktree';
|
|
}>
|
|
) =>
|
|
members.map((member, index) => ({
|
|
id: `draft-${index}`,
|
|
name: member.name,
|
|
originalName: member.name,
|
|
roleSelection: '',
|
|
customRole: member.role ?? '',
|
|
workflow: member.workflow ?? '',
|
|
isolation: member.isolation,
|
|
providerId: member.providerId,
|
|
providerBackendId: member.providerBackendId,
|
|
model: member.model ?? '',
|
|
effort: member.effort,
|
|
fastMode: member.fastMode,
|
|
})),
|
|
filterEditableMemberInputs: (members: unknown) => members,
|
|
normalizeLeadProviderForMode: (providerId: unknown) => providerId,
|
|
normalizeMemberDraftForProviderMode: (member: unknown) => member,
|
|
normalizeProviderForMode: (providerId: unknown) => providerId,
|
|
validateMemberNameInline: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
|
|
TeamRosterEditorSection: (props: any) => {
|
|
teamRosterEditorSectionMock.lastProps = props;
|
|
return React.createElement('div', null, 'team-roster-editor');
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({
|
|
SkipPermissionsCheckbox: () => React.createElement('div', null, 'skip-permissions'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/AdvancedCliSection', () => ({
|
|
AdvancedCliSection: () => React.createElement('div', null, 'advanced-cli'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/OptionalSettingsSection', () => ({
|
|
OptionalSettingsSection: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('div', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/ProjectPathSelector', () => ({
|
|
ProjectPathSelector: ({ selectedProjectPath }: { selectedProjectPath: string }) =>
|
|
React.createElement('div', { 'data-testid': 'project-path' }, selectedProjectPath),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/button', () => ({
|
|
Button: ({
|
|
children,
|
|
onClick,
|
|
type,
|
|
disabled,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}) =>
|
|
React.createElement(
|
|
'button',
|
|
{ type: type ?? 'button', onClick, disabled, className },
|
|
children
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
|
Checkbox: ({
|
|
checked,
|
|
onCheckedChange,
|
|
id,
|
|
}: {
|
|
checked?: boolean;
|
|
onCheckedChange?: (checked: boolean) => void;
|
|
id?: string;
|
|
}) =>
|
|
React.createElement('input', {
|
|
id,
|
|
type: 'checkbox',
|
|
checked,
|
|
onChange: (event: Event) => onCheckedChange?.((event.target as HTMLInputElement).checked),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/combobox', () => ({
|
|
Combobox: () => React.createElement('div', null, 'combobox'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/dialog', () => ({
|
|
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
|
open ? React.createElement('div', null, children) : null,
|
|
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('div', null, children),
|
|
DialogHeader: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('div', null, children),
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('h2', null, children),
|
|
DialogDescription: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('p', null, children),
|
|
DialogFooter: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('div', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/input', () => ({
|
|
Input: (props: Record<string, unknown>) => React.createElement('input', props),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/label', () => ({
|
|
Label: ({
|
|
children,
|
|
htmlFor,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
htmlFor?: string;
|
|
className?: string;
|
|
}) => React.createElement('label', { htmlFor, className }, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
|
|
MentionableTextarea: ({
|
|
value,
|
|
onValueChange,
|
|
id,
|
|
}: {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
id?: string;
|
|
}) =>
|
|
React.createElement('textarea', {
|
|
id,
|
|
value,
|
|
onChange: (event: Event) => onValueChange((event.target as HTMLTextAreaElement).value),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({
|
|
useChipDraftPersistence: () => ({
|
|
chips: [],
|
|
removeChip: vi.fn(),
|
|
addChip: vi.fn(),
|
|
clearChipDraft: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
|
useDraftPersistence: () => {
|
|
const [value, setValue] = React.useState('');
|
|
return {
|
|
value,
|
|
setValue,
|
|
isSaved: false,
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({
|
|
useFileListCacheWarmer: () => undefined,
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
|
|
useTaskSuggestions: () => ({ suggestions: [] }),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
|
useTeamSuggestions: () => ({ suggestions: [] }),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
|
useTheme: () => ({ isLight: false }),
|
|
}));
|
|
|
|
vi.mock('@renderer/utils/geminiUiFreeze', () => ({
|
|
isGeminiUiFrozen: () => false,
|
|
normalizeCreateLaunchProviderForUi: (providerId: unknown) => providerId ?? 'anthropic',
|
|
}));
|
|
|
|
vi.mock('@renderer/utils/teamModelAvailability', () => ({
|
|
getTeamModelSelectionError: vi.fn(() => null),
|
|
isTeamModelAvailableForUi: vi.fn(() => true),
|
|
normalizeExplicitTeamModelForUi: vi.fn((_providerId: string, model: string) => model),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
|
|
buildProviderPrepareModelCacheKey: () => 'prepare-cache-key',
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({
|
|
buildReusableProviderPrepareModelResults: () => ({}),
|
|
getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }),
|
|
runProviderPrepareDiagnostics: vi.fn(async () => ({
|
|
status: 'ready',
|
|
warnings: [],
|
|
details: [],
|
|
modelResultsById: {},
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/provisioningModelIssues', () => ({
|
|
getProvisioningModelIssue: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () => ({
|
|
ProvisioningProviderStatusList: () => React.createElement('div', null, 'provider-status-list'),
|
|
deriveEffectiveProvisioningPrepareState: ({
|
|
state,
|
|
message,
|
|
}: {
|
|
state: 'idle' | 'loading' | 'ready' | 'failed';
|
|
message: string | null;
|
|
}) => ({
|
|
state,
|
|
message,
|
|
}),
|
|
failIncompleteProviderChecks: (checks: unknown) => checks,
|
|
getPrimaryProvisioningFailureDetail: () => null,
|
|
getProvisioningFailureHint: () => 'hint',
|
|
getProvisioningProviderBackendSummary: () => null,
|
|
shouldHideProvisioningProviderStatusList: () => false,
|
|
updateProviderCheck: (checks: unknown) => checks,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
|
TeamModelSelector: ({ value }: { value: string }) =>
|
|
React.createElement('div', { 'data-testid': 'team-model-selector' }, `model:${value}`),
|
|
computeEffectiveTeamModel: (model: string) => model || undefined,
|
|
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
|
[providerId, model, effort].filter(Boolean).join(' '),
|
|
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
|
|
OPENCODE_ONE_SHOT_DISABLED_REASON:
|
|
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
|
EffortLevelSelector: ({ value }: { value: string }) =>
|
|
React.createElement('div', { 'data-testid': 'effort-selector' }, `effort:${value}`),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/AnthropicFastModeSelector', () => ({
|
|
AnthropicFastModeSelector: ({
|
|
value,
|
|
onValueChange,
|
|
}: {
|
|
value: string;
|
|
onValueChange: (value: 'inherit' | 'on' | 'off') => void;
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'fast-mode-selector' },
|
|
React.createElement('span', null, `fast:${value}`),
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
onClick: () => onValueChange('on'),
|
|
},
|
|
'set fast on'
|
|
)
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/CodexFastModeSelector', () => ({
|
|
CodexFastModeSelector: ({
|
|
value,
|
|
onValueChange,
|
|
}: {
|
|
value: string;
|
|
onValueChange: (value: 'inherit' | 'on' | 'off') => void;
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'codex-fast-mode-selector' },
|
|
React.createElement('span', null, `codex-fast:${value}`),
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
onClick: () => onValueChange('on'),
|
|
},
|
|
'set codex fast on'
|
|
)
|
|
),
|
|
}));
|
|
|
|
import { api } from '@renderer/api';
|
|
import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog';
|
|
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
|
import { isTeamModelAvailableForUi } from '@renderer/utils/teamModelAvailability';
|
|
|
|
async function flush(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe('LaunchTeamDialog', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
localStorage.clear();
|
|
vi.clearAllMocks();
|
|
storeState.cliStatus = { providers: [] };
|
|
storeState.launchParamsByTeam = {};
|
|
teamRosterEditorSectionMock.lastProps = null;
|
|
});
|
|
|
|
it('renders relaunch-specific title, warning and submit label', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'relaunch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [{ name: 'alice', role: 'Reviewer' }] as any,
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onRelaunch: vi.fn(async () => {}),
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Relaunch Team');
|
|
expect(host.textContent).toContain('Relaunch will restart the current team run');
|
|
expect(
|
|
Array.from(host.querySelectorAll('button')).some(
|
|
(button) => button.textContent === 'Relaunch team'
|
|
)
|
|
).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('passes existing teammate worktree path info to the roster editor', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [
|
|
{
|
|
name: 'jack',
|
|
role: 'developer',
|
|
isolation: 'worktree',
|
|
cwd: '/tmp/project/.worktrees/jack',
|
|
},
|
|
] as any,
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch: vi.fn(async () => {}),
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
|
|
'draft-0':
|
|
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('preserves existing teammate worktree path info from saved launch request fallback', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
cwd: '/tmp/project',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.5',
|
|
members: [
|
|
{
|
|
name: 'jack',
|
|
role: 'developer',
|
|
isolation: 'worktree',
|
|
cwd: '/tmp/project/.worktrees/jack',
|
|
providerId: 'opencode',
|
|
model: 'openrouter/qwen/qwen3-coder',
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch: vi.fn(async () => {}),
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
|
|
'draft-0':
|
|
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('preserves hidden teammate backend and fast mode metadata before draft launch', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
cwd: '/tmp/project',
|
|
providerId: 'anthropic',
|
|
model: 'opus',
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
} as any);
|
|
const onLaunch = vi.fn(async () => {});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Launch team'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
},
|
|
],
|
|
});
|
|
expect(onLaunch).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
|
|
const onRelaunch = vi.fn(async () => {});
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'relaunch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4',
|
|
effort: 'medium',
|
|
},
|
|
] as any,
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onRelaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Relaunch team'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
});
|
|
|
|
expect(onRelaunch).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(api.teams.replaceMembers)).not.toHaveBeenCalled();
|
|
|
|
const [request, members] = onRelaunch.mock.calls[0] as unknown as [
|
|
{ teamName: string; cwd: string; providerId?: string; model?: string },
|
|
Array<{ name: string; providerId?: string; model?: string }>,
|
|
];
|
|
|
|
expect(request.teamName).toBe('team-alpha');
|
|
expect(request.cwd).toBe('/tmp/project');
|
|
expect(request.providerId).toBe('anthropic');
|
|
expect(request.model).toBe('opus');
|
|
expect(members).toEqual([
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
workflow: '',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4',
|
|
effort: 'medium',
|
|
},
|
|
]);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('launches a saved pure OpenCode team with OpenCode as the lead provider', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.mocked(isTeamModelAvailableForUi).mockImplementation(
|
|
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
|
|
);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
},
|
|
],
|
|
} as any);
|
|
|
|
const onLaunch = vi.fn<(request: { providerId?: string; model?: string }) => Promise<void>>(
|
|
async () => {}
|
|
);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
const opencodePrepareCalls = vi
|
|
.mocked(runProviderPrepareDiagnostics)
|
|
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
|
|
expect(opencodePrepareCalls.length).toBeGreaterThan(0);
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Launch team'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
expect(vi.mocked(api.teams.replaceMembers)).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
},
|
|
],
|
|
});
|
|
expect(onLaunch).toHaveBeenCalledTimes(1);
|
|
const launchRequest = (
|
|
onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]>
|
|
)[0]?.[0] as { providerId?: string; model?: string } | undefined;
|
|
expect(launchRequest).toMatchObject({
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('blocks OpenCode lead launch until a model is selected', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
providerId: 'opencode',
|
|
model: '',
|
|
members: [{ name: 'alice', role: 'Reviewer', providerId: 'opencode' }],
|
|
} as any);
|
|
const onLaunch = vi.fn(async () => {});
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode lead requires a selected model.');
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Launch team'
|
|
);
|
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
|
expect(onLaunch).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('blocks OpenCode lead launch without an OpenCode teammate', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
members: [],
|
|
} as any);
|
|
const onLaunch = vi.fn(async () => {});
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode lead requires at least one OpenCode teammate.');
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Launch team'
|
|
);
|
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
|
expect(onLaunch).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('keeps OpenCode lead mixed-provider launches blocked', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'codex_api_key',
|
|
verificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
selectedBackendId: 'codex-native',
|
|
resolvedBackendId: 'codex-native',
|
|
models: ['gpt-5.4'],
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
|
teamName: 'team-alpha',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
members: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }],
|
|
} as any);
|
|
const onLaunch = vi.fn(async () => {});
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch,
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams');
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Launch team'
|
|
);
|
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
|
expect(onLaunch).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('prefills and saves Anthropic schedule runtime contract including max effort and fast mode', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'anthropic',
|
|
status: 'ready',
|
|
modelCatalog: {
|
|
schemaVersion: 1,
|
|
providerId: 'anthropic',
|
|
source: 'anthropic-models-api',
|
|
status: 'ready',
|
|
fetchedAt: '2026-04-21T00:00:00.000Z',
|
|
defaultLaunchModel: 'claude-opus-4-6',
|
|
models: [
|
|
{
|
|
id: 'claude-opus-4-6',
|
|
launchModel: 'claude-opus-4-6',
|
|
displayName: 'Opus 4.6',
|
|
hidden: false,
|
|
supportedReasoningEfforts: ['low', 'medium', 'high', 'max'],
|
|
defaultReasoningEffort: 'high',
|
|
supportsFastMode: true,
|
|
source: 'anthropic-models-api',
|
|
},
|
|
],
|
|
},
|
|
runtimeCapabilities: {
|
|
fastMode: {
|
|
supported: true,
|
|
available: true,
|
|
reason: null,
|
|
source: 'runtime',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'schedule',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
onClose: vi.fn(),
|
|
schedule: {
|
|
id: 'schedule-1',
|
|
teamName: 'team-alpha',
|
|
label: 'Nightly',
|
|
cronExpression: '0 9 * * 1-5',
|
|
timezone: 'UTC',
|
|
status: 'active',
|
|
warmUpMinutes: 15,
|
|
maxConsecutiveFailures: 3,
|
|
consecutiveFailures: 0,
|
|
maxTurns: 50,
|
|
createdAt: '2026-04-21T00:00:00.000Z',
|
|
updatedAt: '2026-04-21T00:00:00.000Z',
|
|
launchConfig: {
|
|
cwd: '/tmp/project',
|
|
prompt: 'Run the scheduled check',
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus-4-6',
|
|
effort: 'max',
|
|
fastMode: 'on',
|
|
resolvedFastMode: true,
|
|
skipPermissions: true,
|
|
},
|
|
} as any,
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
expect(host.textContent).toContain('model:claude-opus-4-6');
|
|
expect(host.textContent).toContain('effort:max');
|
|
expect(host.textContent).toContain('fast:on');
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Save Changes'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
});
|
|
|
|
expect(updateSchedule).toHaveBeenCalledTimes(1);
|
|
expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({
|
|
launchConfig: {
|
|
cwd: '/tmp/project',
|
|
prompt: 'Run the scheduled check',
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus-4-6',
|
|
effort: 'max',
|
|
fastMode: 'on',
|
|
resolvedFastMode: true,
|
|
skipPermissions: true,
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('preserves Codex schedule backend lane and effort in edit saves', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'codex',
|
|
status: 'ready',
|
|
selectedBackendId: 'codex-native',
|
|
resolvedBackendId: 'codex-native',
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'schedule',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
onClose: vi.fn(),
|
|
schedule: {
|
|
id: 'schedule-2',
|
|
teamName: 'team-alpha',
|
|
label: 'Codex job',
|
|
cronExpression: '0 10 * * 1-5',
|
|
timezone: 'UTC',
|
|
status: 'active',
|
|
warmUpMinutes: 15,
|
|
maxConsecutiveFailures: 3,
|
|
consecutiveFailures: 0,
|
|
maxTurns: 50,
|
|
createdAt: '2026-04-21T00:00:00.000Z',
|
|
updatedAt: '2026-04-21T00:00:00.000Z',
|
|
launchConfig: {
|
|
cwd: '/tmp/project',
|
|
prompt: 'Run Codex scheduled check',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'xhigh',
|
|
skipPermissions: true,
|
|
},
|
|
} as any,
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Save Changes'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
});
|
|
|
|
expect(updateSchedule).toHaveBeenCalledTimes(1);
|
|
expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({
|
|
launchConfig: {
|
|
cwd: '/tmp/project',
|
|
prompt: 'Run Codex scheduled check',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'xhigh',
|
|
fastMode: 'inherit',
|
|
resolvedFastMode: false,
|
|
skipPermissions: true,
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('saves Codex schedule Fast mode when GPT-5.4 ChatGPT eligibility is available', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'codex',
|
|
status: 'ready',
|
|
authenticated: true,
|
|
authMethod: 'chatgpt',
|
|
selectedBackendId: 'codex-native',
|
|
resolvedBackendId: 'codex-native',
|
|
modelCatalog: {
|
|
schemaVersion: 1,
|
|
providerId: 'codex',
|
|
source: 'app-server',
|
|
status: 'ready',
|
|
fetchedAt: '2026-04-21T00:00:00.000Z',
|
|
defaultModelId: 'gpt-5.4',
|
|
defaultLaunchModel: 'gpt-5.4',
|
|
models: [
|
|
{
|
|
id: 'gpt-5.4',
|
|
launchModel: 'gpt-5.4',
|
|
displayName: 'GPT-5.4',
|
|
hidden: false,
|
|
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
|
|
defaultReasoningEffort: 'medium',
|
|
source: 'app-server',
|
|
},
|
|
],
|
|
},
|
|
connection: {
|
|
codex: {
|
|
effectiveAuthMode: 'chatgpt',
|
|
launchAllowed: true,
|
|
launchIssueMessage: null,
|
|
launchReadinessState: 'ready_chatgpt',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'schedule',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
onClose: vi.fn(),
|
|
schedule: {
|
|
id: 'schedule-3',
|
|
teamName: 'team-alpha',
|
|
label: 'Codex fast job',
|
|
cronExpression: '0 10 * * 1-5',
|
|
timezone: 'UTC',
|
|
status: 'active',
|
|
warmUpMinutes: 15,
|
|
maxConsecutiveFailures: 3,
|
|
consecutiveFailures: 0,
|
|
maxTurns: 50,
|
|
createdAt: '2026-04-21T00:00:00.000Z',
|
|
updatedAt: '2026-04-21T00:00:00.000Z',
|
|
launchConfig: {
|
|
cwd: '/tmp/project',
|
|
prompt: 'Run Codex scheduled check',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'xhigh',
|
|
fastMode: 'inherit',
|
|
resolvedFastMode: false,
|
|
skipPermissions: true,
|
|
},
|
|
} as any,
|
|
})
|
|
);
|
|
await flush();
|
|
});
|
|
|
|
const fastButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'set codex fast on'
|
|
);
|
|
expect(fastButton).toBeTruthy();
|
|
await act(async () => {
|
|
fastButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
});
|
|
|
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Save Changes'
|
|
);
|
|
expect(submitButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flush();
|
|
});
|
|
|
|
expect(updateSchedule).toHaveBeenCalledTimes(1);
|
|
expect(updateSchedule.mock.calls[0]?.[1]).toMatchObject({
|
|
launchConfig: {
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4',
|
|
effort: 'xhigh',
|
|
fastMode: 'on',
|
|
resolvedFastMode: true,
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('does not restart provider preflight when cli status refresh keeps the same semantic inputs', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'codex',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'chatgpt',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
selectedBackendId: 'codex-native',
|
|
resolvedBackendId: 'codex-native',
|
|
models: ['gpt-5.4'],
|
|
modelCatalog: {
|
|
source: 'app-server',
|
|
status: 'ready',
|
|
models: [{ id: 'gpt-5.4' }],
|
|
},
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
const renderDialog = async (): Promise<void> => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [],
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch: vi.fn(async () => {}),
|
|
})
|
|
);
|
|
await flush();
|
|
await flush();
|
|
};
|
|
|
|
await act(async () => {
|
|
await renderDialog();
|
|
});
|
|
|
|
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1);
|
|
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'codex',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'chatgpt',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
selectedBackendId: 'codex-native',
|
|
resolvedBackendId: 'codex-native',
|
|
models: ['gpt-5.4'],
|
|
modelCatalog: {
|
|
source: 'app-server',
|
|
status: 'ready',
|
|
models: [{ id: 'gpt-5.4' }],
|
|
},
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
await act(async () => {
|
|
await renderDialog();
|
|
});
|
|
|
|
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
|
|
it('keeps the in-flight preflight result after a same-signature rerender', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'verified',
|
|
statusMessage: 'warming up',
|
|
detailMessage: 'first render',
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
modelCatalog: {
|
|
source: 'app-server',
|
|
status: 'ready',
|
|
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
|
},
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
let resolvePrepare!: (value: {
|
|
status: 'ready';
|
|
warnings: [];
|
|
details: [];
|
|
modelResultsById: {};
|
|
}) => void;
|
|
const preparePromise = new Promise<{
|
|
status: 'ready';
|
|
warnings: [];
|
|
details: [];
|
|
modelResultsById: {};
|
|
}>((resolve) => {
|
|
resolvePrepare = resolve;
|
|
});
|
|
vi.mocked(runProviderPrepareDiagnostics).mockReturnValueOnce(preparePromise as any);
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
const renderDialog = async (): Promise<void> => {
|
|
root.render(
|
|
React.createElement(LaunchTeamDialog, {
|
|
mode: 'launch',
|
|
open: true,
|
|
teamName: 'team-alpha',
|
|
members: [
|
|
{
|
|
name: 'alice',
|
|
role: 'Reviewer',
|
|
providerId: 'opencode',
|
|
model: 'opencode/minimax-m2.5-free',
|
|
},
|
|
] as any,
|
|
defaultProjectPath: '/tmp/project',
|
|
provisioningError: null,
|
|
clearProvisioningError: vi.fn(),
|
|
activeTeams: [],
|
|
onClose: vi.fn(),
|
|
onLaunch: vi.fn(async () => {}),
|
|
})
|
|
);
|
|
await flush();
|
|
};
|
|
|
|
await act(async () => {
|
|
await renderDialog();
|
|
});
|
|
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
providers: [
|
|
{
|
|
providerId: 'opencode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'verified',
|
|
statusMessage: 'still warming',
|
|
detailMessage: 'same semantic status',
|
|
models: ['opencode/minimax-m2.5-free'],
|
|
modelCatalog: {
|
|
source: 'app-server',
|
|
status: 'ready',
|
|
models: [{ id: 'opencode/minimax-m2.5-free' }],
|
|
},
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: false,
|
|
},
|
|
},
|
|
],
|
|
} as any;
|
|
|
|
await act(async () => {
|
|
await renderDialog();
|
|
});
|
|
|
|
await act(async () => {
|
|
resolvePrepare({
|
|
status: 'ready',
|
|
warnings: [],
|
|
details: [],
|
|
modelResultsById: {},
|
|
});
|
|
await flush();
|
|
await flush();
|
|
});
|
|
|
|
const inFlightOpencodePrepareCalls = vi
|
|
.mocked(runProviderPrepareDiagnostics)
|
|
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
|
|
expect(inFlightOpencodePrepareCalls).toHaveLength(1);
|
|
expect(host.textContent).toContain('Selected providers are ready.');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flush();
|
|
});
|
|
});
|
|
});
|