236 lines
7.8 KiB
TypeScript
236 lines
7.8 KiB
TypeScript
import { QueryClientProvider } from '@tanstack/react-query'
|
|
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { renderWithEnglish as render } from './test-locale'
|
|
|
|
const { requestGateway, getProfiles } = vi.hoisted(() => ({
|
|
requestGateway: vi.fn(),
|
|
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
|
|
profiles: []
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
|
|
useGatewayRequest: () => ({ requestGateway })
|
|
}))
|
|
|
|
vi.mock('@/hermes', async importOriginal => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
getProfiles
|
|
}))
|
|
|
|
import { $pluginRecords } from '@/contrib/plugins-store'
|
|
import { queryClient } from '@/lib/query-client'
|
|
import {
|
|
$agentPluginBusy,
|
|
$agentPlugins,
|
|
$agentPluginsError,
|
|
$agentPluginsStatus,
|
|
type AgentPluginRow
|
|
} from '@/store/agent-plugins'
|
|
import { $activeGatewayProfile } from '@/store/profile'
|
|
import { $connection, $gatewayState } from '@/store/session'
|
|
|
|
import { PluginsSettings } from './plugins-settings'
|
|
|
|
const legacyRow = {
|
|
name: 'Legacy plugin',
|
|
version: '0.20.0',
|
|
description: 'Returned by a pre-key backend',
|
|
source: 'user',
|
|
status: 'disabled'
|
|
} satisfies AgentPluginRow
|
|
|
|
const renderSettings = () =>
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<PluginsSettings />
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
beforeEach(() => {
|
|
requestGateway.mockReset()
|
|
getProfiles.mockReset()
|
|
getProfiles.mockResolvedValue({ profiles: [] })
|
|
queryClient.clear()
|
|
$pluginRecords.set({})
|
|
$agentPlugins.set([legacyRow])
|
|
$agentPluginsStatus.set('ready')
|
|
$agentPluginsError.set(null)
|
|
$agentPluginBusy.set(null)
|
|
$gatewayState.set('idle')
|
|
$connection.set(null)
|
|
$activeGatewayProfile.set('default')
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('PluginsSettings', () => {
|
|
it('renders and searches plugin rows returned without a canonical key', () => {
|
|
renderSettings()
|
|
|
|
expect(screen.getByText('Legacy plugin')).toBeTruthy()
|
|
|
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'pre-key' } })
|
|
|
|
expect(screen.getByText('Legacy plugin')).toBeTruthy()
|
|
})
|
|
|
|
it('renders keyless rows read-only instead of falling back to name-addressed toggles', () => {
|
|
// Name-addressed toggles flip every same-named plugin across category
|
|
// dirs (image_gen/fal vs video_gen/fal) — the reason toggles moved to
|
|
// canonical keys. A pre-contract-v6 row must never reach the RPC.
|
|
renderSettings()
|
|
|
|
const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' })
|
|
|
|
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
|
|
|
|
fireEvent.click(toggle)
|
|
|
|
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
|
|
})
|
|
|
|
it('keeps duplicate-named keyless rows distinct (no React key collision)', () => {
|
|
const sibling = {
|
|
...legacyRow,
|
|
description: 'A second plugin category with the same legacy name'
|
|
}
|
|
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
$agentPlugins.set([legacyRow, sibling])
|
|
|
|
renderSettings()
|
|
|
|
expect(screen.getAllByRole('switch', { name: 'Enable Legacy plugin' })).toHaveLength(2)
|
|
expect(screen.getByText(sibling.description)).toBeTruthy()
|
|
expect(consoleError.mock.calls.flat().join(' ')).not.toContain('same key')
|
|
})
|
|
|
|
it('keeps using the canonical key when the backend provides one', async () => {
|
|
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
|
|
|
|
$agentPlugins.set([keyedRow])
|
|
requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } })
|
|
|
|
renderSettings()
|
|
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
|
|
|
|
await waitFor(() =>
|
|
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
|
|
action: 'toggle',
|
|
key: 'image_gen/legacy',
|
|
enable: true
|
|
})
|
|
)
|
|
})
|
|
|
|
it('hides repo-bundled built-ins and keeps the count pill in sync', () => {
|
|
// The Agent plugins section is the control panel for plugins the USER
|
|
// installed — built-ins (browser backends, cron providers, model
|
|
// providers…) ship enabled-by-default and are configured elsewhere.
|
|
$agentPlugins.set([
|
|
legacyRow,
|
|
{ ...legacyRow, name: 'browserbase', key: 'browser/browserbase', source: 'bundled' },
|
|
{ ...legacyRow, name: 'chronos', key: 'cron_providers/chronos', source: 'bundled' },
|
|
{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'bundled' }
|
|
])
|
|
|
|
renderSettings()
|
|
|
|
expect(screen.getByText('Legacy plugin')).toBeTruthy()
|
|
expect(screen.queryByText('browserbase')).toBeNull()
|
|
expect(screen.queryByText('chronos')).toBeNull()
|
|
expect(screen.queryByText('deepinfra')).toBeNull()
|
|
// Count pill reflects the filtered list, not the raw RPC row count.
|
|
expect(screen.getByText('1 installed', { exact: false })).toBeTruthy()
|
|
})
|
|
|
|
it('hides legacy other-surface categories even when the backend omits source', () => {
|
|
// Older backends may not report source reliably — the key-prefix
|
|
// fallback still hides categories other surfaces own.
|
|
$agentPlugins.set([{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'user' }])
|
|
|
|
renderSettings()
|
|
|
|
expect(screen.queryByText('deepinfra')).toBeNull()
|
|
})
|
|
|
|
it('shows no profile selector with a single profile', async () => {
|
|
getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
|
|
|
|
renderSettings()
|
|
|
|
await waitFor(() => expect(getProfiles).toHaveBeenCalled())
|
|
expect(screen.queryByText('Applies to:')).toBeNull()
|
|
})
|
|
|
|
it('lists the active profile scope without a profile param and reloads scoped on change', async () => {
|
|
getProfiles.mockResolvedValue({
|
|
profiles: [
|
|
{ name: 'default', is_default: true },
|
|
{ name: 'work', is_default: false }
|
|
]
|
|
})
|
|
requestGateway.mockResolvedValue({ plugins: [legacyRow] })
|
|
$gatewayState.set('open')
|
|
|
|
renderSettings()
|
|
|
|
// Active profile scope: no profile param — older backends unchanged.
|
|
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list' }))
|
|
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
|
|
})
|
|
|
|
it('sends toggles through the selected profile scope', async () => {
|
|
// jsdom's scrollIntoView is missing/non-functional; Radix Select calls it
|
|
// when the dropdown opens.
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
|
|
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
|
|
|
|
getProfiles.mockResolvedValue({
|
|
profiles: [
|
|
{ name: 'default', is_default: true },
|
|
{ name: 'work', is_default: false }
|
|
]
|
|
})
|
|
requestGateway.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
|
|
if (params?.action === 'list') {
|
|
return { plugins: [keyedRow] }
|
|
}
|
|
|
|
return { ok: true, plugin: { ...keyedRow, status: 'enabled' } }
|
|
})
|
|
$gatewayState.set('open')
|
|
|
|
renderSettings()
|
|
|
|
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
|
|
|
|
// Select the non-active profile scope.
|
|
fireEvent.click(screen.getByRole('combobox'))
|
|
fireEvent.click(await screen.findByText('work'))
|
|
|
|
await waitFor(() =>
|
|
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list', profile: 'work' })
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
|
|
|
|
await waitFor(() =>
|
|
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
|
|
action: 'toggle',
|
|
key: 'image_gen/legacy',
|
|
enable: true,
|
|
profile: 'work'
|
|
})
|
|
)
|
|
})
|
|
})
|