Files
TREK/client/src/components/Settings/PluginSettingsTab.test.tsx
T
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00

515 lines
22 KiB
TypeScript

// FE-COMP-PLUGINSETTINGS-001 to FE-COMP-PLUGINSETTINGS-025
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import { server } from '../../../tests/helpers/msw/server';
import { ToastContainer } from '../shared/Toast';
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore';
import type { PluginAction, PluginUserSettingField } from '../../api/client';
import PluginSettingsTab from './PluginSettingsTab';
const plugin = (over: Partial<ActivePlugin> = {}): ActivePlugin => ({
id: 'weather',
name: 'Weather',
type: 'widget',
icon: 'Cloud',
...over,
});
interface SettingsPayload {
fields?: PluginUserSettingField[]
config?: Record<string, unknown>
actions?: PluginAction[]
}
/** Wire the three per-plugin endpoints the form reads on mount. */
function serve(id: string, settings: SettingsPayload, oauth?: { configured: boolean; connected: boolean }): void {
server.use(
http.get(`/api/plugin-settings/${id}`, () => HttpResponse.json({
fields: settings.fields ?? [],
config: settings.config ?? {},
actions: settings.actions ?? [],
})),
http.get(`/api/plugin-oauth/${id}/status`, () =>
oauth ? HttpResponse.json(oauth) : HttpResponse.json({ error: 'not found' }, { status: 404 })),
);
}
function setPlugins(plugins: ActivePlugin[]): void {
usePluginStore.setState({ plugins });
}
/** The settings card for a plugin (the heading's rounded container). */
const cardFor = (name: string) => screen.getByRole('heading', { name }).closest('div.rounded-xl') as HTMLElement;
beforeEach(() => {
vi.clearAllMocks();
setPlugins([]);
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: [] })));
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('PluginSettingsTab', () => {
it('FE-COMP-PLUGINSETTINGS-001: shows the header and the empty state when no plugin is active', async () => {
render(<PluginSettingsTab />);
expect(screen.getByRole('heading', { name: 'Plugin settings' })).toBeInTheDocument();
expect(screen.getByText('Your personal settings for the plugins you use (API keys, preferences).')).toBeInTheDocument();
expect(screen.getByText('No plugins are active.')).toBeInTheDocument();
// The activity log is always mounted, plugins or not.
expect(await screen.findByText('Plugin activity')).toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-002: a plugin with nothing to configure renders no card', async () => {
serve('weather', {});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await screen.findByText('Plugin activity');
expect(screen.queryByText('No plugins are active.')).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Weather' })).not.toBeInTheDocument());
});
it('FE-COMP-PLUGINSETTINGS-003: a failing settings request leaves the card out', async () => {
server.use(
http.get('/api/plugin-settings/weather', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.get('/api/plugin-oauth/weather/status', () => HttpResponse.json({ configured: false, connected: false })),
);
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await screen.findByText('Plugin activity');
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Weather' })).not.toBeInTheDocument());
});
it('FE-COMP-PLUGINSETTINGS-004: text fields are labelled, prefilled and marked required', async () => {
serve('weather', {
fields: [
{ key: 'api_key', label: 'API key', input_type: 'text', required: true, hint: 'From your provider' },
{ key: 'units', input_type: 'text' },
],
config: { api_key: 'abc123', units: 'metric' },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await screen.findByDisplayValue('abc123');
const card = cardFor('Weather');
expect(within(card).getByText('API key')).toHaveTextContent('*');
expect(within(card).getByText('From your provider')).toBeInTheDocument();
// No label declared → the key stands in for it.
expect(within(card).getByText('units')).toBeInTheDocument();
expect(within(card).getByDisplayValue('metric')).toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-005: a secret field renders as a password input with a placeholder', async () => {
serve('weather', {
fields: [{ key: 'token', label: 'Token', input_type: 'text', secret: true, placeholder: 'paste here' }],
config: { token: '••••••••' },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const input = await screen.findByPlaceholderText('paste here') as HTMLInputElement;
expect(input.type).toBe('password');
expect(input.autocomplete).toBe('new-password');
expect(input.value).toBe('••••••••');
});
it('FE-COMP-PLUGINSETTINGS-006: a number field renders a numeric input and a null config value is blank', async () => {
serve('weather', {
fields: [{ key: 'days', label: 'Days', input_type: 'number' }],
config: { days: null },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const input = await screen.findByRole('spinbutton') as HTMLInputElement;
expect(input.type).toBe('number');
expect(input.value).toBe('');
});
it('FE-COMP-PLUGINSETTINGS-007: a checkbox field mirrors the boolean config and toggles', async () => {
const user = userEvent.setup();
serve('weather', {
fields: [{ key: 'alerts', label: 'Alerts', input_type: 'checkbox' }],
config: { alerts: true },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const box = await screen.findByRole('checkbox');
expect(box).toBeChecked();
await user.click(box);
expect(box).not.toBeChecked();
});
it('FE-COMP-PLUGINSETTINGS-008: a select field lists its options and keeps the chosen one', async () => {
const user = userEvent.setup();
serve('weather', {
fields: [{
key: 'unit', label: 'Unit', input_type: 'select',
options: [{ value: 'c', label: 'Celsius' }, { value: 'f', label: 'Fahrenheit' }],
}],
config: { unit: 'c' },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const select = await screen.findByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('c');
await user.selectOptions(select, 'f');
expect(select.value).toBe('f');
});
it('FE-COMP-PLUGINSETTINGS-009: a select field without options falls back to a text input', async () => {
serve('weather', {
fields: [{ key: 'unit', label: 'Unit', input_type: 'select' }],
config: { unit: 'c' },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
expect(await screen.findByDisplayValue('c')).toHaveAttribute('type', 'text');
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-010: saving posts the edited values and echoes back the stored config', async () => {
const user = userEvent.setup();
const sent: { body?: { config?: Record<string, unknown> } } = {};
serve('weather', {
fields: [
{ key: 'api_key', label: 'API key', input_type: 'text' },
{ key: 'alerts', label: 'Alerts', input_type: 'checkbox' },
],
config: { api_key: 'old', alerts: false },
});
server.use(http.post('/api/plugin-settings/weather', async ({ request }) => {
sent.body = await request.json() as { config?: Record<string, unknown> };
return HttpResponse.json({ config: { api_key: 'new-key', alerts: true } });
}));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
const input = await screen.findByDisplayValue('old');
await user.clear(input);
await user.type(input, 'new-key');
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Settings saved');
expect(sent.body?.config).toEqual({ api_key: 'new-key', alerts: true });
expect(await screen.findByDisplayValue('new-key')).toBeInTheDocument();
expect(screen.getByRole('checkbox')).toBeChecked();
});
it('FE-COMP-PLUGINSETTINGS-011: an untouched secret is not overwritten with its mask', async () => {
const user = userEvent.setup();
const sent: { body?: { config?: Record<string, unknown> } } = {};
serve('weather', {
fields: [
{ key: 'token', label: 'Token', input_type: 'text', secret: true },
{ key: 'city', label: 'City', input_type: 'text' },
],
config: { token: '••••••••', city: 'Berlin' },
});
server.use(http.post('/api/plugin-settings/weather', async ({ request }) => {
sent.body = await request.json() as { config?: Record<string, unknown> };
return HttpResponse.json({ config: { token: '••••••••', city: 'Paris' } });
}));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
const city = await screen.findByDisplayValue('Berlin');
await user.clear(city);
await user.type(city, 'Paris');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Settings saved');
expect(sent.body?.config).toEqual({ city: 'Paris' });
});
it('FE-COMP-PLUGINSETTINGS-012: a changed secret IS sent', async () => {
const user = userEvent.setup();
const sent: { body?: { config?: Record<string, unknown> } } = {};
serve('weather', {
fields: [{ key: 'token', label: 'Token', input_type: 'text', secret: true }],
config: { token: '••••••••' },
});
server.use(http.post('/api/plugin-settings/weather', async ({ request }) => {
sent.body = await request.json() as { config?: Record<string, unknown> };
return HttpResponse.json({ config: { token: '••••••••' } });
}));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
const token = await screen.findByDisplayValue('••••••••');
await user.clear(token);
await user.type(token, 'real-secret');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Settings saved');
expect(sent.body?.config).toEqual({ token: 'real-secret' });
});
it('FE-COMP-PLUGINSETTINGS-013: a failing save reports an error and re-enables the button', async () => {
const user = userEvent.setup();
serve('weather', {
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
config: { city: 'Berlin' },
});
server.use(http.post('/api/plugin-settings/weather', () =>
HttpResponse.json({ error: 'nope' }, { status: 500 })));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
await screen.findByDisplayValue('Berlin');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Error');
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});
it('FE-COMP-PLUGINSETTINGS-014: a plugin with only actions gets a card without a Save button', async () => {
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false, scope: 'user' }] });
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await screen.findByRole('button', { name: 'Test connection' });
const card = cardFor('Weather');
expect(within(card).getByText('Actions')).toBeInTheDocument();
expect(within(card).getByText('Pings the API')).toBeInTheDocument();
expect(within(card).queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-015: running an action shows the message it returns', async () => {
const user = userEvent.setup();
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
HttpResponse.json({ ok: true, message: 'Reached the API' })));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await user.click(await screen.findByRole('button', { name: 'Test connection' }));
expect(await screen.findByText('Reached the API')).toHaveClass('text-success');
});
it('FE-COMP-PLUGINSETTINGS-016: a message-less result falls back to the generic ok/error labels', async () => {
const user = userEvent.setup();
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: false })));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await user.click(await screen.findByRole('button', { name: 'Test connection' }));
expect(await screen.findByText('Error')).toHaveClass('text-danger');
});
it('FE-COMP-PLUGINSETTINGS-017: a failing action reports an error result', async () => {
const user = userEvent.setup();
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
HttpResponse.json({ error: 'down' }, { status: 500 })));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const button = await screen.findByRole('button', { name: 'Test connection' });
await user.click(button);
expect(await screen.findByText('Error')).toHaveClass('text-danger');
expect(button).toBeEnabled();
});
it('FE-COMP-PLUGINSETTINGS-018: a dangerous action asks first and a declined confirm skips it', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
let ran = 0;
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
ran += 1;
return HttpResponse.json({ ok: true, message: 'Wiped' });
}));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await user.click(await screen.findByRole('button', { name: 'Wipe cache' }));
expect(confirmSpy).toHaveBeenCalledWith('Run this action?');
expect(ran).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByRole('button', { name: 'Wipe cache' }));
await screen.findByText('Wiped');
expect(ran).toBe(1);
confirmSpy.mockRestore();
});
it('FE-COMP-PLUGINSETTINGS-019: an unconfigured OAuth integration renders no connect row', async () => {
serve('weather', {
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
config: { city: 'Berlin' },
}, { configured: false, connected: false });
setPlugins([plugin()]);
render(<PluginSettingsTab />);
await screen.findByDisplayValue('Berlin');
expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument();
expect(screen.queryByText('Not connected')).not.toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-020: an OAuth-only plugin gets a card and Connect hands off to the provider', async () => {
const user = userEvent.setup();
const loc = { href: 'http://localhost/settings', origin: 'http://localhost', pathname: '/settings' };
vi.stubGlobal('location', loc);
serve('weather', {}, { configured: true, connected: false });
server.use(http.post('/api/plugin-oauth/weather/connect', () =>
HttpResponse.json({ authorizeUrl: 'https://provider.example/authorize?x=1' })));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
expect(await screen.findByText('Not connected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Connect' }));
await waitFor(() => expect(loc.href).toBe('https://provider.example/authorize?x=1'));
});
it('FE-COMP-PLUGINSETTINGS-021: a failing connect reports an error and frees the button', async () => {
const user = userEvent.setup();
serve('weather', {}, { configured: true, connected: false });
server.use(http.post('/api/plugin-oauth/weather/connect', () =>
HttpResponse.json({ error: 'nope' }, { status: 500 })));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
await user.click(await screen.findByRole('button', { name: 'Connect' }));
await screen.findByText('Error');
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled());
});
it('FE-COMP-PLUGINSETTINGS-022: disconnecting flips the row back to "not connected"', async () => {
const user = userEvent.setup();
serve('weather', {}, { configured: true, connected: true });
server.use(http.post('/api/plugin-oauth/weather/disconnect', () => HttpResponse.json({ connected: false })));
setPlugins([plugin()]);
render(<PluginSettingsTab />);
expect(await screen.findByText('Connected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
expect(await screen.findByText('Not connected')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-023: a failing disconnect keeps the connection and reports an error', async () => {
const user = userEvent.setup();
serve('weather', {}, { configured: true, connected: true });
server.use(http.post('/api/plugin-oauth/weather/disconnect', () =>
HttpResponse.json({ error: 'nope' }, { status: 500 })));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
await user.click(await screen.findByRole('button', { name: 'Disconnect' }));
await screen.findByText('Error');
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeEnabled();
});
it('FE-COMP-PLUGINSETTINGS-024: a settingsUi plugin also gets its own sandboxed frame card', async () => {
serve('weather', {
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
config: { city: 'Berlin' },
});
setPlugins([plugin({ settingsUi: true })]);
const { container } = render(<PluginSettingsTab />);
await screen.findByDisplayValue('Berlin');
expect(screen.getAllByRole('heading', { name: 'Weather' })).toHaveLength(2);
const frame = container.querySelector('iframe') as HTMLIFrameElement;
expect(frame).not.toBeNull();
expect(frame.getAttribute('src')).toBe('/plugin-frame/weather/settings.html');
expect(frame.getAttribute('sandbox') || '').not.toContain('allow-same-origin');
});
it('FE-COMP-PLUGINSETTINGS-025: every active plugin gets its own form', async () => {
serve('weather', { fields: [{ key: 'city', label: 'City', input_type: 'text' }], config: { city: 'Berlin' } });
server.use(
http.get('/api/plugin-settings/notes', () => HttpResponse.json({
fields: [{ key: 'folder', label: 'Folder', input_type: 'text' }], config: { folder: 'Trips' }, actions: [],
})),
http.get('/api/plugin-oauth/notes/status', () => HttpResponse.json({ configured: false, connected: false })),
);
setPlugins([plugin(), plugin({ id: 'notes', name: 'Notes', icon: null })]);
render(<PluginSettingsTab />);
await screen.findByDisplayValue('Berlin');
expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument();
expect(within(cardFor('Notes')).getByText('Folder')).toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-026: pre-fills a declared default when no value is stored', async () => {
serve('weather', {
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, default: 'https://auth.openbnb.org/authorize' }],
config: {},
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize')).toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-027: a stored value wins over the default', async () => {
serve('weather', {
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', default: 'https://auth.openbnb.org/authorize' }],
config: { oauth_authorize_url: 'https://mine.example' },
});
setPlugins([plugin()]);
render(<PluginSettingsTab />);
expect(await screen.findByDisplayValue('https://mine.example')).toBeInTheDocument();
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument();
});
it('FE-COMP-PLUGINSETTINGS-028: blocks Save while a required field is empty and names it', async () => {
const user = userEvent.setup();
let called = false;
serve('weather', {
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true }],
config: {},
});
server.use(http.post('/api/plugin-settings/weather', () => {
called = true;
return HttpResponse.json({ config: {} });
}));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
await screen.findByText('Client ID');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('"Client ID" is required')).toBeInTheDocument();
expect(called).toBe(false);
});
it('FE-COMP-PLUGINSETTINGS-029: a 400 from the server names the field it refused; a 500 stays generic', async () => {
// The field list was loaded before a plugin update made `city` required, so the
// client pre-check passes and only the server knows — its message must reach the user.
const user = userEvent.setup();
serve('weather', {
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
config: {},
});
server.use(http.post('/api/plugin-settings/weather', () =>
HttpResponse.json({ error: 'Missing required setting "city"' }, { status: 400 })));
setPlugins([plugin()]);
render(<><ToastContainer /><PluginSettingsTab /></>);
await screen.findByText('City');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Missing required setting "city"')).toBeInTheDocument();
});
});