|
| 1 | +/** |
| 2 | + * @vitest-environment jsdom |
| 3 | + */ |
| 4 | +import { act } from 'react' |
| 5 | +import { createRoot } from 'react-dom/client' |
| 6 | +import { renderToStaticMarkup } from 'react-dom/server' |
| 7 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 8 | +import { |
| 9 | + getDeploymentShape, |
| 10 | + resetDeploymentShape, |
| 11 | + resolveDeploymentShape, |
| 12 | + seedDeploymentShape, |
| 13 | +} from '@/lib/core/config/deployment-shape' |
| 14 | + |
| 15 | +const { subBlockValues, mockSetValue } = vi.hoisted(() => ({ |
| 16 | + subBlockValues: { |
| 17 | + model: 'claude-sonnet-5' as string, |
| 18 | + fallbackModels: [] as Array<{ |
| 19 | + id: string |
| 20 | + model: string |
| 21 | + apiKey?: string |
| 22 | + reasoningEffort?: string |
| 23 | + }>, |
| 24 | + }, |
| 25 | + mockSetValue: vi.fn(), |
| 26 | +})) |
| 27 | + |
| 28 | +vi.mock('next/navigation', () => ({ |
| 29 | + useParams: () => ({ workspaceId: 'workspace-1' }), |
| 30 | +})) |
| 31 | + |
| 32 | +vi.mock('@sim/emcn', () => ({ |
| 33 | + Chip: ({ |
| 34 | + children, |
| 35 | + disabled, |
| 36 | + 'aria-label': ariaLabel, |
| 37 | + }: { |
| 38 | + children?: React.ReactNode |
| 39 | + disabled?: boolean |
| 40 | + 'aria-label'?: string |
| 41 | + }) => ( |
| 42 | + <button type='button' disabled={disabled} aria-label={ariaLabel}> |
| 43 | + {children} |
| 44 | + </button> |
| 45 | + ), |
| 46 | + ChipCombobox: ({ |
| 47 | + options, |
| 48 | + value, |
| 49 | + placeholder, |
| 50 | + }: { |
| 51 | + options: Array<{ value: string; label: string; disabled?: boolean }> |
| 52 | + value?: string |
| 53 | + placeholder?: string |
| 54 | + }) => ( |
| 55 | + <div data-combobox={placeholder} data-value={value}> |
| 56 | + {options.map((option) => ( |
| 57 | + <span key={option.value} data-disabled={option.disabled ? 'true' : undefined}> |
| 58 | + {option.label} |
| 59 | + </span> |
| 60 | + ))} |
| 61 | + </div> |
| 62 | + ), |
| 63 | + ChipDropdown: ({ |
| 64 | + options, |
| 65 | + value, |
| 66 | + placeholder, |
| 67 | + }: { |
| 68 | + options: Array<{ value: string; label: string }> |
| 69 | + value?: string |
| 70 | + placeholder?: string |
| 71 | + }) => ( |
| 72 | + <div data-combobox={placeholder} data-value={value}> |
| 73 | + {options.map((option) => ( |
| 74 | + <span key={option.value}>{option.label}</span> |
| 75 | + ))} |
| 76 | + </div> |
| 77 | + ), |
| 78 | + Label: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>, |
| 79 | + Tooltip: { |
| 80 | + Root: ({ children }: { children?: React.ReactNode }) => <>{children}</>, |
| 81 | + Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>, |
| 82 | + Content: () => null, |
| 83 | + }, |
| 84 | +})) |
| 85 | + |
| 86 | +vi.mock('@sim/emcn/icons', () => ({ |
| 87 | + ChevronDown: () => null, |
| 88 | + ChevronUp: () => null, |
| 89 | + Plus: () => null, |
| 90 | + Trash: () => null, |
| 91 | +})) |
| 92 | + |
| 93 | +vi.mock( |
| 94 | + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', |
| 95 | + () => ({ |
| 96 | + useSubBlockValue: (_blockId: string, subBlockId: string) => [ |
| 97 | + subBlockId === 'model' || subBlockId === 'fallbackModels' ? subBlockValues[subBlockId] : null, |
| 98 | + mockSetValue, |
| 99 | + ], |
| 100 | + }) |
| 101 | +) |
| 102 | + |
| 103 | +vi.mock('@/hooks/queries/environment', () => ({ |
| 104 | + usePersonalEnvironment: () => ({ data: { PERSONAL_KEY: 'x' } }), |
| 105 | + useWorkspaceEnvironment: () => ({ |
| 106 | + data: { workspace: { OPENROUTER_API_KEY: 'x' }, personal: {}, conflicts: [] }, |
| 107 | + }), |
| 108 | +})) |
| 109 | + |
| 110 | +vi.mock('@/hooks/use-permission-config', () => ({ |
| 111 | + usePermissionConfig: () => ({ isModelUsable: (model: string) => model !== 'denied-model' }), |
| 112 | +})) |
| 113 | + |
| 114 | +vi.mock('@/hooks/use-settings-navigation', () => ({ |
| 115 | + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), |
| 116 | +})) |
| 117 | + |
| 118 | +vi.mock('@/lib/credentials/client-state', () => ({ |
| 119 | + writePendingCredentialCreateRequest: vi.fn(), |
| 120 | +})) |
| 121 | + |
| 122 | +vi.mock('@/stores/providers/store', () => ({ |
| 123 | + useProvidersStore: (selector: (state: { providers: object }) => unknown) => |
| 124 | + selector({ providers: {} }), |
| 125 | +})) |
| 126 | + |
| 127 | +vi.mock('@/blocks/utils', () => ({ |
| 128 | + shouldRequireApiKeyForModel: (model: string) => |
| 129 | + model.startsWith('openrouter/') || (model.startsWith('gpt') && !getDeploymentShape().hosted), |
| 130 | + getModelOptions: () => [ |
| 131 | + { id: 'claude-sonnet-5', label: 'claude-sonnet-5' }, |
| 132 | + { id: 'gpt-5', label: 'gpt-5' }, |
| 133 | + { id: 'denied-model', label: 'denied-model' }, |
| 134 | + { id: 'openrouter/x', label: 'openrouter/x' }, |
| 135 | + { id: 'sim-auto', label: 'Auto' }, |
| 136 | + ], |
| 137 | +})) |
| 138 | + |
| 139 | +vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => { |
| 140 | + const actual = await importOriginal<typeof import('@/lib/workflows/blocks/fallback-models')>() |
| 141 | + return { |
| 142 | + ...actual, |
| 143 | + isViableFallbackModel: (model: string, primary: string) => |
| 144 | + model !== 'sim-auto' && model !== primary, |
| 145 | + getFallbackTuningKnobsToShow: (model: string) => (model === 'gpt-5' ? ['reasoningEffort'] : []), |
| 146 | + getTuningOptionsForModel: (model: string, knob: string) => |
| 147 | + model === 'gpt-5' && knob === 'reasoningEffort' ? ['auto', 'low', 'high'] : null, |
| 148 | + } |
| 149 | +}) |
| 150 | + |
| 151 | +import { ModelFallbackList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list' |
| 152 | + |
| 153 | +beforeEach(() => { |
| 154 | + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) |
| 155 | + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) |
| 156 | +}) |
| 157 | + |
| 158 | +afterEach(() => { |
| 159 | + resetDeploymentShape() |
| 160 | + vi.unstubAllGlobals() |
| 161 | +}) |
| 162 | + |
| 163 | +function render(extra: Partial<React.ComponentProps<typeof ModelFallbackList>> = {}) { |
| 164 | + return renderToStaticMarkup( |
| 165 | + <ModelFallbackList blockId='block-1' subBlockId='fallbackModels' disabled={false} {...extra} /> |
| 166 | + ) |
| 167 | +} |
| 168 | + |
| 169 | +describe('ModelFallbackList', () => { |
| 170 | + beforeEach(() => { |
| 171 | + subBlockValues.model = 'claude-sonnet-5' |
| 172 | + subBlockValues.fallbackModels = [] |
| 173 | + mockSetValue.mockReset() |
| 174 | + }) |
| 175 | + |
| 176 | + it('renders only the add affordance when nothing is configured', () => { |
| 177 | + const html = render() |
| 178 | + expect(html).toContain('Add fallback model') |
| 179 | + expect(html).not.toContain('choice') |
| 180 | + }) |
| 181 | + |
| 182 | + it('labels rows as ordinal choices and offers viable, permitted models', () => { |
| 183 | + subBlockValues.fallbackModels = [ |
| 184 | + { id: 'r1', model: 'gpt-5' }, |
| 185 | + { id: 'r2', model: '' }, |
| 186 | + ] |
| 187 | + const html = render() |
| 188 | + expect(html).toContain('2nd choice') |
| 189 | + expect(html).toContain('3rd choice') |
| 190 | + expect(html).not.toContain('Auto') |
| 191 | + expect(html).not.toContain('denied-model') |
| 192 | + /** The primary is never offered. A model another row holds is disabled there, never in its own row. */ |
| 193 | + expect(html).not.toContain('>claude-sonnet-5<') |
| 194 | + expect(html.match(/data-disabled="true">gpt-5</g)).toHaveLength(1) |
| 195 | + expect(html.match(/>gpt-5</g)).toHaveLength(2) |
| 196 | + expect(html).toContain('aria-label="Move up"') |
| 197 | + }) |
| 198 | + |
| 199 | + it('renders no move controls for a single row and never shows a non-reference key', () => { |
| 200 | + subBlockValues.fallbackModels = [ |
| 201 | + { id: 'r1', model: 'openrouter/x', apiKey: 'sk-raw-through-socket' }, |
| 202 | + ] |
| 203 | + const html = render() |
| 204 | + expect(html).not.toContain('aria-label="Move up"') |
| 205 | + expect(html).not.toContain('sk-raw-through-socket') |
| 206 | + expect(html).toContain('data-combobox="Select a secret" data-value=""') |
| 207 | + }) |
| 208 | + |
| 209 | + it('asks for an environment variable only when the row model needs its own key', () => { |
| 210 | + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] |
| 211 | + expect(render()).not.toContain('data-combobox="Select a secret"') |
| 212 | + |
| 213 | + subBlockValues.fallbackModels = [ |
| 214 | + { id: 'r1', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, |
| 215 | + ] |
| 216 | + const html = render() |
| 217 | + expect(html).toContain('data-combobox="Select a secret"') |
| 218 | + expect(html).toContain('data-value="{{OPENROUTER_API_KEY}}"') |
| 219 | + expect(html).toContain('OPENROUTER_API_KEY') |
| 220 | + expect(html).toContain('Create Secret') |
| 221 | + }) |
| 222 | + |
| 223 | + it('updates key visibility when hosted context arrives after mount, without rewriting the rows', async () => { |
| 224 | + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: false }) |
| 225 | + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] |
| 226 | + const container = document.createElement('div') |
| 227 | + const root = createRoot(container) |
| 228 | + try { |
| 229 | + await act(async () => { |
| 230 | + root.render(<ModelFallbackList blockId='block-1' subBlockId='fallbackModels' />) |
| 231 | + }) |
| 232 | + expect(container.querySelector('[data-combobox="Select a secret"]')).not.toBeNull() |
| 233 | + |
| 234 | + await act(async () => { |
| 235 | + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) |
| 236 | + }) |
| 237 | + expect(container.querySelector('[data-combobox="Select a secret"]')).toBeNull() |
| 238 | + expect(mockSetValue).not.toHaveBeenCalled() |
| 239 | + } finally { |
| 240 | + await act(async () => root.unmount()) |
| 241 | + } |
| 242 | + }) |
| 243 | + |
| 244 | + it('shows a tuning field only for the knobs the helper says need one', () => { |
| 245 | + subBlockValues.fallbackModels = [ |
| 246 | + { id: 'r1', model: 'gpt-5', reasoningEffort: 'low' }, |
| 247 | + { id: 'r2', model: 'openrouter/x' }, |
| 248 | + ] |
| 249 | + const html = render() |
| 250 | + expect(html).toContain('data-combobox="Select reasoning effort" data-value="low"') |
| 251 | + expect(html.match(/Select reasoning effort/g)).toHaveLength(1) |
| 252 | + expect(html).not.toContain('Thinking level') |
| 253 | + }) |
| 254 | + |
| 255 | + it('gates a preview against the previewed primary, not the live block', () => { |
| 256 | + /** The live block selects claude-sonnet-5; the previewed version selected gpt-5. */ |
| 257 | + const html = render({ |
| 258 | + isPreview: true, |
| 259 | + previewValue: [{ id: 'r1', model: 'openrouter/x' }], |
| 260 | + previewPrimary: { model: 'gpt-5' }, |
| 261 | + }) |
| 262 | + expect(html).not.toContain('>gpt-5<') |
| 263 | + expect(html).toContain('>claude-sonnet-5<') |
| 264 | + expect(html).not.toContain('Add fallback model') |
| 265 | + }) |
| 266 | + |
| 267 | + it('disables the add affordance at the cap', () => { |
| 268 | + subBlockValues.fallbackModels = Array.from({ length: 5 }, (_, i) => ({ |
| 269 | + id: `r${i}`, |
| 270 | + model: `m-${i}`, |
| 271 | + })) |
| 272 | + const html = render() |
| 273 | + expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Add fallback model/) |
| 274 | + }) |
| 275 | +}) |
0 commit comments