543 lines
17 KiB
TypeScript
543 lines
17 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
|
|
import { ANTHROPIC_LONG_CONTEXT_PRICING_URL } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
|
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
|
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
|
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
|
[providerId, model || 'Default', effort].filter(Boolean).join(' · '),
|
|
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
|
getTeamEffortLabel: (effort: string) => effort || 'Default',
|
|
getTeamProviderLabel: (providerId: string) => providerId,
|
|
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/RoleSelect', () => ({
|
|
RoleSelect: ({ value }: { value: string }) => React.createElement('div', null, value),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/button', () => ({
|
|
Button: ({
|
|
children,
|
|
className,
|
|
onClick,
|
|
disabled,
|
|
title,
|
|
'aria-describedby': ariaDescribedBy,
|
|
'aria-expanded': ariaExpanded,
|
|
'aria-label': ariaLabel,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
'aria-describedby'?: string;
|
|
'aria-expanded'?: boolean;
|
|
'aria-label'?: string;
|
|
}) =>
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
className,
|
|
onClick,
|
|
disabled,
|
|
title,
|
|
'aria-describedby': ariaDescribedBy,
|
|
'aria-expanded': ariaExpanded,
|
|
'aria-label': ariaLabel,
|
|
},
|
|
children
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/checkbox', () => ({
|
|
Checkbox: ({
|
|
checked,
|
|
onCheckedChange,
|
|
...props
|
|
}: {
|
|
checked?: boolean;
|
|
onCheckedChange?: (value: boolean) => void;
|
|
}) =>
|
|
React.createElement('input', {
|
|
...props,
|
|
checked,
|
|
type: 'checkbox',
|
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
onCheckedChange?.(event.target.checked),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/input', () => ({
|
|
Input: ({
|
|
value,
|
|
onChange,
|
|
...props
|
|
}: React.InputHTMLAttributes<HTMLInputElement> & { value?: string }) =>
|
|
React.createElement('input', { ...props, value, onChange, type: 'text' }),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/label', () => ({
|
|
Label: ({
|
|
children,
|
|
...props
|
|
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
|
|
React.createElement('label', props, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
|
|
MentionableTextarea: () => React.createElement('textarea'),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useDraftPersistence', () => ({
|
|
useDraftPersistence: ({ initialValue }: { initialValue?: string }) => ({
|
|
value: initialValue ?? '',
|
|
setValue: () => undefined,
|
|
isSaved: true,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({
|
|
useFileListCacheWarmer: () => undefined,
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
|
useTheme: () => ({ isLight: false }),
|
|
}));
|
|
|
|
import { MemberDraftRow } from './MemberDraftRow';
|
|
import { createMemberDraft } from './membersEditorUtils';
|
|
|
|
function renderMemberDraftRow(props: Partial<React.ComponentProps<typeof MemberDraftRow>> = {}): {
|
|
host: HTMLDivElement;
|
|
root: ReturnType<typeof createRoot>;
|
|
} {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
act(() => {
|
|
root.render(
|
|
React.createElement(MemberDraftRow, {
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: 'anthropic',
|
|
model: 'opus',
|
|
}),
|
|
index: 0,
|
|
nameError: null,
|
|
onNameChange: () => undefined,
|
|
onRoleChange: () => undefined,
|
|
onCustomRoleChange: () => undefined,
|
|
onRemove: () => undefined,
|
|
onProviderChange: () => undefined,
|
|
onModelChange: () => undefined,
|
|
onEffortChange: () => undefined,
|
|
...props,
|
|
})
|
|
);
|
|
});
|
|
|
|
return { host, root };
|
|
}
|
|
|
|
describe('MemberDraftRow', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('does not show the sync tooltip copy when model controls are unlocked', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
lockProviderModel: false,
|
|
forceInheritedModelSettings: false,
|
|
modelLockReason:
|
|
'This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort.',
|
|
});
|
|
|
|
expect(host.textContent).not.toContain('This teammate is synced with the lead model');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('renders workflow and MCP row controls as icon-only buttons with tooltip text', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
showWorkflow: true,
|
|
onWorkflowChange: () => undefined,
|
|
onMcpPolicyChange: () => undefined,
|
|
});
|
|
|
|
const workflowButton = host.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Add teammate workflow"]'
|
|
)!;
|
|
const mcpButton = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find(
|
|
(button) =>
|
|
button.getAttribute('aria-label') ===
|
|
"MCP inherit: Control this member's MCP inheritance policy"
|
|
)!;
|
|
const removeButton = host.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Remove alice"]'
|
|
)!;
|
|
|
|
expect(workflowButton).toBeTruthy();
|
|
expect(workflowButton.textContent).not.toContain('Workflow');
|
|
expect(workflowButton.closest('[title]')?.getAttribute('title')).toBe('Add teammate workflow');
|
|
expect(workflowButton.getAttribute('aria-expanded')).toBe('false');
|
|
|
|
expect(mcpButton).toBeTruthy();
|
|
expect(mcpButton.textContent).not.toContain('MCP');
|
|
expect(mcpButton.textContent).not.toContain('inherit');
|
|
const mcpTooltipWrapper = mcpButton.closest('[title]');
|
|
const mcpTooltipContent = Array.from(mcpTooltipWrapper?.children ?? []).find(
|
|
(element) => element.getAttribute('aria-hidden') === 'true'
|
|
);
|
|
expect(mcpTooltipWrapper?.getAttribute('title')).toBe(
|
|
"MCP inherit: Control this member's MCP inheritance policy"
|
|
);
|
|
expect(mcpTooltipContent?.getAttribute('class')).toContain(
|
|
'group-hover/hover-tooltip:opacity-100'
|
|
);
|
|
expect(mcpButton.getAttribute('aria-expanded')).toBe('false');
|
|
expect(
|
|
workflowButton.compareDocumentPosition(mcpButton) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
).toBeTruthy();
|
|
expect(
|
|
mcpButton.compareDocumentPosition(removeButton) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
).toBeTruthy();
|
|
|
|
act(() => {
|
|
workflowButton.click();
|
|
mcpButton.click();
|
|
});
|
|
|
|
expect(workflowButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(mcpButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(mcpButton.className).toContain('border-sky-400/45');
|
|
expect(mcpTooltipWrapper?.getAttribute('title')).toBeNull();
|
|
expect(mcpTooltipContent?.getAttribute('class')).not.toContain(
|
|
'group-hover/hover-tooltip:opacity-100'
|
|
);
|
|
expect(host.textContent).toContain('Workflow (optional)');
|
|
expect(host.textContent).toContain('MCP mode');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: 'inherit lead',
|
|
mcpPolicy: undefined,
|
|
ariaLabel: "MCP inherit: Control this member's MCP inheritance policy",
|
|
highlightedBeforeClick: false,
|
|
},
|
|
{
|
|
label: 'agent teams mcp',
|
|
mcpPolicy: { mode: 'appOnly' as const },
|
|
ariaLabel: "Agent Teams MCP: Control this member's MCP inheritance policy",
|
|
highlightedBeforeClick: true,
|
|
},
|
|
{
|
|
label: 'scope inheritance',
|
|
mcpPolicy: {
|
|
mode: 'inheritScopes' as const,
|
|
scopes: { user: true, project: false, local: true },
|
|
},
|
|
ariaLabel: "MCP scopes: Control this member's MCP inheritance policy",
|
|
highlightedBeforeClick: true,
|
|
},
|
|
{
|
|
label: 'strict allowlist',
|
|
mcpPolicy: {
|
|
mode: 'strictAllowlist' as const,
|
|
scopes: { user: true, project: true, local: false },
|
|
serverNames: ['github', 'linear'],
|
|
},
|
|
ariaLabel: "MCP 2: Control this member's MCP inheritance policy",
|
|
highlightedBeforeClick: true,
|
|
},
|
|
])(
|
|
'keeps MCP control state correct across $label settings in the row fixture e2e',
|
|
({ mcpPolicy, ariaLabel, highlightedBeforeClick }) => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: 'anthropic',
|
|
model: 'opus',
|
|
mcpPolicy,
|
|
}),
|
|
onMcpPolicyChange: () => undefined,
|
|
});
|
|
|
|
const mcpButton = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find(
|
|
(button) => button.getAttribute('aria-label') === ariaLabel
|
|
)!;
|
|
const tooltipWrapper = mcpButton.closest('[title]');
|
|
const tooltipContent = Array.from(tooltipWrapper?.children ?? []).find(
|
|
(element) => element.getAttribute('aria-hidden') === 'true'
|
|
);
|
|
|
|
expect(mcpButton).toBeTruthy();
|
|
expect(mcpButton.textContent).not.toContain('MCP');
|
|
expect(mcpButton.className.includes('border-sky-400/45')).toBe(highlightedBeforeClick);
|
|
expect(tooltipWrapper?.getAttribute('title')).toBe(ariaLabel);
|
|
expect(tooltipContent?.getAttribute('class')).toContain(
|
|
'group-hover/hover-tooltip:opacity-100'
|
|
);
|
|
|
|
act(() => {
|
|
mcpButton.click();
|
|
});
|
|
|
|
expect(mcpButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(mcpButton.className).toContain('border-sky-400/45');
|
|
expect(tooltipWrapper?.getAttribute('title')).toBeNull();
|
|
expect(tooltipContent?.getAttribute('class')).not.toContain(
|
|
'group-hover/hover-tooltip:opacity-100'
|
|
);
|
|
expect(host.textContent).toContain('MCP mode');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
);
|
|
|
|
it('locks MCP controls when Agent Teams MCP master mode is enabled', () => {
|
|
const onMcpPolicyChange = vi.fn();
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: 'anthropic',
|
|
model: 'opus',
|
|
mcpPolicy: {
|
|
mode: 'strictAllowlist',
|
|
scopes: { user: true, project: true, local: true },
|
|
serverNames: ['github'],
|
|
},
|
|
}),
|
|
onMcpPolicyChange,
|
|
agentTeamsMcpLocked: true,
|
|
});
|
|
|
|
const mcpButton = Array.from(host.querySelectorAll<HTMLButtonElement>('button')).find(
|
|
(button) =>
|
|
button.getAttribute('aria-label') ===
|
|
"Agent Teams MCP: Control this member's MCP inheritance policy"
|
|
)!;
|
|
|
|
expect(mcpButton).toBeTruthy();
|
|
expect(mcpButton.className).toContain('border-amber-300/50');
|
|
expect(mcpButton.querySelector('.bg-amber-300')).toBeTruthy();
|
|
|
|
act(() => {
|
|
mcpButton.click();
|
|
});
|
|
|
|
expect(host.textContent).toContain('MCP mode');
|
|
expect(host.textContent).toContain('Agent Teams MCP');
|
|
expect(host.textContent).toContain(
|
|
'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.'
|
|
);
|
|
|
|
const mcpModeTrigger = host.querySelector<HTMLButtonElement>('#member-member-1-mcp-mode')!;
|
|
const scopeCheckboxes = Array.from(
|
|
host.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
|
|
);
|
|
|
|
expect(mcpModeTrigger.disabled).toBe(true);
|
|
expect(scopeCheckboxes).toHaveLength(3);
|
|
expect(scopeCheckboxes.every((checkbox) => checkbox.disabled)).toBe(true);
|
|
|
|
act(() => {
|
|
scopeCheckboxes[0]?.click();
|
|
});
|
|
|
|
expect(onMcpPolicyChange).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('shows inherited model copy when sync is enabled', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
lockProviderModel: true,
|
|
forceInheritedModelSettings: true,
|
|
});
|
|
|
|
expect(host.textContent).toContain(
|
|
'Provider, model, and effort are inherited from the lead while sync is enabled.'
|
|
);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('explains that Anthropic context limit is team-wide for teammate overrides', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
limitContext: true,
|
|
});
|
|
|
|
const modelButton = host.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="anthropic provider, opus"]'
|
|
)!;
|
|
act(() => {
|
|
modelButton.click();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Anthropic context is team-wide for this launch');
|
|
expect(host.textContent).toContain('200K limit enabled');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('shows model launch issues inline and keeps model controls expandable', () => {
|
|
const issueText =
|
|
'Member alice uses Anthropic effort "medium", but Haiku 4.5 does not support it in the current runtime.';
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: 'anthropic',
|
|
model: 'claude-haiku-4-5-20251001',
|
|
effort: 'medium',
|
|
}),
|
|
modelIssueText: issueText,
|
|
});
|
|
|
|
const modelButton = host.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="anthropic provider, claude-haiku-4-5-20251001"]'
|
|
)!;
|
|
|
|
expect(host.textContent).toContain(issueText);
|
|
expect(modelButton.getAttribute('aria-describedby')).toContain('member-member-1-model-issue');
|
|
expect(modelButton.parentElement?.getAttribute('title')).toBe(issueText);
|
|
|
|
act(() => {
|
|
modelButton.click();
|
|
});
|
|
|
|
expect(host.textContent).toContain('team-model-selector');
|
|
expect(host.textContent).toContain('effort-selector');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('renders worktree isolation help without a Radix tooltip trigger', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
showWorktreeIsolationControls: true,
|
|
worktreeIsolationDisabledReason: 'Worktree isolation is disabled for this project.',
|
|
});
|
|
|
|
const worktreeControl = host.querySelector<HTMLInputElement>(
|
|
'#member-member-1-worktree-isolation'
|
|
)!;
|
|
const descriptionId = 'member-member-1-worktree-isolation-description';
|
|
const wrapper = worktreeControl.closest('[title]');
|
|
|
|
expect(worktreeControl.getAttribute('aria-describedby')).toBe(descriptionId);
|
|
expect(wrapper?.getAttribute('title')).toBe('Worktree isolation is disabled for this project.');
|
|
expect(host.querySelector(`#${descriptionId}`)?.textContent).toBe(
|
|
'Worktree isolation is disabled for this project.'
|
|
);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('warns custom Anthropic Sonnet teammates about plan/runtime billing when 200K limit is off', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: {
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
customRole: '',
|
|
providerId: 'anthropic',
|
|
model: 'sonnet[1m]',
|
|
},
|
|
limitContext: false,
|
|
});
|
|
|
|
expect(host.textContent).toContain('Sonnet 1M context can affect billing');
|
|
expect(host.textContent).toContain('Extra Usage for Sonnet 1M');
|
|
const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`);
|
|
|
|
expect(docsLink?.textContent).toContain('Anthropic pricing docs');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('does not warn standard-context Anthropic Sonnet teammates about Extra Usage', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: 'anthropic',
|
|
model: 'sonnet',
|
|
}),
|
|
limitContext: false,
|
|
});
|
|
|
|
expect(host.textContent).not.toContain('Anthropic Extra Usage');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it('does not duplicate the Sonnet Extra Usage warning for effort-only inherited teammates', () => {
|
|
const { host, root } = renderMemberDraftRow({
|
|
member: createMemberDraft({
|
|
id: 'member-1',
|
|
name: 'alice',
|
|
roleSelection: 'developer',
|
|
providerId: undefined,
|
|
model: '',
|
|
effort: 'max',
|
|
}),
|
|
inheritedProviderId: 'anthropic',
|
|
inheritedModel: 'sonnet[1m]',
|
|
limitContext: false,
|
|
});
|
|
|
|
expect(host.textContent).not.toContain('Anthropic Extra Usage');
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|