mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
b82962f95b
* 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
461 lines
21 KiB
TypeScript
461 lines
21 KiB
TypeScript
// FE-MOB-SETPLG-001 onwards
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { render, screen, waitFor } from '../../../helpers/render';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import { resetAllStores, seedStore } from '../../../helpers/store';
|
|
import { buildSettings } from '../../../helpers/factories';
|
|
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
|
import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore';
|
|
import type { PluginAction, PluginUserSettingField } from '../../../../src/api/client';
|
|
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
|
import MSettingsPlugins from '../../../../src/mobile/screens/settings/MSettingsPlugins';
|
|
|
|
// The plugin's own settings.html lives behind a sandboxed iframe + postMessage
|
|
// bridge — out of scope here, so the frame is stubbed to its identifying props.
|
|
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
|
default: ({ pluginId, path, title }: { pluginId: string; path?: string; title?: string }) => (
|
|
<div data-testid="plugin-frame" data-plugin={pluginId} data-path={path} data-title={title} />
|
|
),
|
|
}));
|
|
|
|
const SECRET_MASK = '••••••••';
|
|
|
|
const PLUGIN: ActivePlugin = { id: 'p1', name: 'Weather Pro', type: 'integration', icon: 'Cloud' };
|
|
|
|
const FIELDS: PluginUserSettingField[] = [
|
|
{ key: 'api_key', label: 'API key', input_type: 'text', secret: true, required: true, placeholder: 'sk-key', hint: 'From the provider dashboard' },
|
|
{ key: 'endpoint', label: 'Endpoint', input_type: 'text', placeholder: 'https://api.example' },
|
|
{ key: 'retries', label: 'Retries', input_type: 'number', placeholder: '3' },
|
|
{ key: 'notify', label: 'Notify me', input_type: 'checkbox', hint: 'Send a push' },
|
|
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }] },
|
|
{ key: 'raw' },
|
|
];
|
|
|
|
const CONFIG: Record<string, unknown> = {
|
|
api_key: SECRET_MASK,
|
|
endpoint: 'https://api.example',
|
|
retries: 3,
|
|
notify: true,
|
|
mode: null,
|
|
raw: null,
|
|
};
|
|
|
|
const ACTIONS: PluginAction[] = [
|
|
{ key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false, scope: 'user' },
|
|
{ key: 'reset', label: 'Reset cache', danger: true, scope: 'user' },
|
|
];
|
|
|
|
const ACTIVITY = [
|
|
{ ts: '2025-05-01T10:00:00.000Z', plugin_id: 'p1', plugin_name: 'Weather Pro', method: 'GET', resource: '/trips/1', code: 'ok' },
|
|
{ ts: 'not-a-date', plugin_id: 'p2', plugin_name: null, method: 'POST', resource: null, code: 'FORBIDDEN' },
|
|
{ ts: '2025-05-02T10:00:00.000Z', plugin_id: 'p3', plugin_name: 'Sync', method: 'PUT', resource: '/atlas', code: 'RATE_LIMIT' },
|
|
];
|
|
|
|
interface SettingsRoute {
|
|
fields?: PluginUserSettingField[]
|
|
config?: Record<string, unknown>
|
|
actions?: PluginAction[]
|
|
oauth?: { configured: boolean; connected: boolean }
|
|
}
|
|
|
|
function seedPluginApi(id: string, r: SettingsRoute) {
|
|
server.use(
|
|
http.get(`/api/plugin-settings/${id}`, () =>
|
|
HttpResponse.json({ fields: r.fields ?? [], config: r.config ?? {}, actions: r.actions ?? [] })),
|
|
http.get(`/api/plugin-oauth/${id}/status`, () =>
|
|
HttpResponse.json(r.oauth ?? { configured: false, connected: false })),
|
|
);
|
|
}
|
|
|
|
function renderScreen() {
|
|
return render(<><ToastContainer /><MSettingsPlugins /></>);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
|
usePluginStore.setState({ plugins: [], loaded: true });
|
|
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: [] })));
|
|
});
|
|
|
|
describe('MSettingsPlugins', () => {
|
|
it('FE-MOB-SETPLG-001: without active plugins only the empty note and the activity card render', async () => {
|
|
renderScreen();
|
|
|
|
expect(screen.getByText('No plugins are active.')).toBeInTheDocument();
|
|
expect(screen.getByText('Plugin activity')).toBeInTheDocument();
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
await screen.findByText('No plugin activity yet.');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-002: the activity log renders one row per entry with its status tone', async () => {
|
|
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: ACTIVITY })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
// Nameless plugin falls back to its id, missing resource to an em dash
|
|
expect(screen.getByText('p2')).toBeInTheDocument();
|
|
expect(screen.getByText('—')).toBeInTheDocument();
|
|
expect(screen.getByText('/trips/1')).toBeInTheDocument();
|
|
// An unparsable timestamp is shown verbatim
|
|
expect(screen.getByText('not-a-date')).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('ok')).toHaveClass('text-m-muted');
|
|
expect(screen.getByText('FORBIDDEN')).toHaveClass('text-[color:var(--m-st-danger)]');
|
|
expect(screen.getByText('RATE_LIMIT')).toHaveClass('text-[color:var(--m-st-pending)]');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-003: refresh re-requests the activity log', async () => {
|
|
let calls = 0;
|
|
server.use(http.get('/api/plugin-activity', () => { calls += 1; return HttpResponse.json({ activity: ACTIVITY }); }));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
expect(calls).toBe(1);
|
|
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' }));
|
|
await waitFor(() => expect(calls).toBe(2));
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-004: a failing activity request degrades to the empty state', async () => {
|
|
server.use(http.get('/api/plugin-activity', () => HttpResponse.json({}, { status: 500 })));
|
|
renderScreen();
|
|
|
|
expect(await screen.findByText('No plugin activity yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-005: declared user fields render as the matching mobile controls', async () => {
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
const secret = screen.getByPlaceholderText('sk-key');
|
|
expect(secret).toHaveAttribute('type', 'password');
|
|
expect(secret).toHaveValue(SECRET_MASK);
|
|
expect(screen.getByText('*')).toBeInTheDocument();
|
|
expect(screen.getByText('From the provider dashboard')).toBeInTheDocument();
|
|
|
|
expect(screen.getByPlaceholderText('https://api.example')).toHaveAttribute('type', 'text');
|
|
expect(screen.getByPlaceholderText('3')).toHaveAttribute('type', 'number');
|
|
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'true');
|
|
// No option matches the null config value
|
|
expect(screen.getByRole('button', { name: '—' })).toBeInTheDocument();
|
|
// A field without a label falls back to its key
|
|
expect(screen.getByText('raw')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-006: saving posts every field but leaves an untouched secret alone', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { config?: Record<string, unknown> } | null = null;
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { fields: FIELDS, config: CONFIG });
|
|
server.use(
|
|
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
|
body = (await request.json()) as { config: Record<string, unknown> };
|
|
return HttpResponse.json({ config: { ...CONFIG, endpoint: 'https://new.example' } });
|
|
}),
|
|
);
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
const endpoint = screen.getByPlaceholderText('https://api.example');
|
|
await user.clear(endpoint);
|
|
await user.type(endpoint, 'https://new.example');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await screen.findByText('Settings saved');
|
|
expect(body?.config).toEqual({
|
|
endpoint: 'https://new.example',
|
|
retries: '3',
|
|
notify: true,
|
|
mode: '',
|
|
raw: '',
|
|
});
|
|
expect(body?.config).not.toHaveProperty('api_key');
|
|
expect(endpoint).toHaveValue('https://new.example');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-007: an edited secret is included in the patch', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { config?: Record<string, unknown> } | null = null;
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { fields: [FIELDS[0]], config: { api_key: SECRET_MASK } });
|
|
server.use(
|
|
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
|
body = (await request.json()) as { config: Record<string, unknown> };
|
|
return HttpResponse.json({ config: { api_key: SECRET_MASK } });
|
|
}),
|
|
);
|
|
renderScreen();
|
|
|
|
const secret = await screen.findByPlaceholderText('sk-key');
|
|
await user.clear(secret);
|
|
await user.type(secret, 'sk-live-123');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await waitFor(() => expect(body?.config).toEqual({ api_key: 'sk-live-123' }));
|
|
// The response re-masks it
|
|
expect(secret).toHaveValue(SECRET_MASK);
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-008: the checkbox and select controls feed their new values into the patch', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { config?: Record<string, unknown> } | null = null;
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { fields: [FIELDS[3], FIELDS[4]], config: { notify: true, mode: null } });
|
|
server.use(
|
|
http.post('/api/plugin-settings/p1', async ({ request }) => {
|
|
body = (await request.json()) as { config: Record<string, unknown> };
|
|
return HttpResponse.json({ config: { notify: false, mode: 'slow' } });
|
|
}),
|
|
);
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
await user.click(screen.getByRole('switch', { name: 'Notify me' }));
|
|
await user.click(screen.getByRole('button', { name: '—' }));
|
|
|
|
// Picker sheet lists the empty option plus the declared ones
|
|
await screen.findByRole('button', { name: 'Fast' });
|
|
await user.click(screen.getByRole('button', { name: 'Slow' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await waitFor(() => expect(body?.config).toEqual({ notify: false, mode: 'slow' }));
|
|
expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'false');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-009: a failed save toasts the error and keeps the edit', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
|
server.use(http.post('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })));
|
|
renderScreen();
|
|
|
|
const endpoint = await screen.findByPlaceholderText('https://api.example');
|
|
await user.clear(endpoint);
|
|
await user.type(endpoint, 'https://broken');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await screen.findByText('Error');
|
|
expect(endpoint).toHaveValue('https://broken');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-010: a plugin with no fields, actions or OAuth renders no card', async () => {
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', {});
|
|
renderScreen();
|
|
|
|
await screen.findByText('No plugin activity yet.');
|
|
expect(screen.queryByText('Weather Pro')).toBeNull();
|
|
expect(screen.queryByText('No plugins are active.')).toBeNull();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-011: a failing settings request is fail-safe — no card, no crash', async () => {
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
server.use(
|
|
http.get('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })),
|
|
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({}, { status: 500 })),
|
|
);
|
|
renderScreen();
|
|
|
|
await screen.findByText('No plugin activity yet.');
|
|
expect(screen.queryByText('Weather Pro')).toBeNull();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-012: a plain action runs straight away and shows its result', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { actions: ACTIONS });
|
|
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true, message: 'Reached the provider' })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Actions');
|
|
expect(screen.getByText('Pings the provider')).toBeInTheDocument();
|
|
// Without user fields there is no Save button
|
|
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
|
await screen.findByText('Reached the provider');
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-013: an action result without a message falls back to the generic label', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false, scope: 'user' }] });
|
|
server.use(
|
|
http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true })),
|
|
http.post('/api/plugin-settings/p1/actions/probe', () => HttpResponse.json({ ok: false })),
|
|
);
|
|
renderScreen();
|
|
|
|
await screen.findByText('Actions');
|
|
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
|
await screen.findByText('Success');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Probe' }));
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-014: unlabelled fields fall back to their key, hints render below', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
server.use(
|
|
// No `actions` key at all — the form must still render.
|
|
http.get('/api/plugin-settings/p1', () => HttpResponse.json({
|
|
fields: [
|
|
{ key: 'flag', input_type: 'checkbox', hint: 'Toggle it' },
|
|
{ key: 'pick', input_type: 'select', hint: 'Choose one', options: [{ value: 'a', label: 'Alpha' }] },
|
|
],
|
|
config: {},
|
|
})),
|
|
http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({ configured: false, connected: false })),
|
|
);
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
expect(screen.getByRole('switch', { name: 'flag' })).toHaveAttribute('aria-checked', 'false');
|
|
expect(screen.getByText('Toggle it')).toBeInTheDocument();
|
|
expect(screen.getByText('Choose one')).toBeInTheDocument();
|
|
expect(screen.queryByText('Actions')).toBeNull();
|
|
|
|
// The picker sheet is titled with the key too
|
|
await user.click(screen.getByRole('button', { name: '—' }));
|
|
await waitFor(() => expect(screen.getAllByText('pick').length).toBeGreaterThan(1));
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-015: a failing action reports an error result', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { actions: [ACTIONS[0]] });
|
|
server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({}, { status: 500 })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Actions');
|
|
await user.click(screen.getByRole('button', { name: 'Test connection' }));
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-016: a danger action asks for confirmation before it runs', async () => {
|
|
const user = userEvent.setup();
|
|
let ran = 0;
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { actions: [ACTIONS[1]] });
|
|
server.use(http.post('/api/plugin-settings/p1/actions/reset', () => { ran += 1; return HttpResponse.json({ ok: true, message: 'Cache cleared' }); }));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Actions');
|
|
|
|
// Cancelling runs nothing
|
|
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
|
|
await screen.findByText('Run this action?');
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull());
|
|
expect(ran).toBe(0);
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Reset cache' }));
|
|
await screen.findByText('Run this action?');
|
|
const confirms = screen.getAllByRole('button', { name: 'Reset cache' });
|
|
await user.click(confirms[confirms.length - 1]);
|
|
|
|
await screen.findByText('Cache cleared');
|
|
expect(ran).toBe(1);
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-017: an unconfigured OAuth plugin shows no connect controls', async () => {
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { actions: [ACTIONS[0]], oauth: { configured: false, connected: false } });
|
|
renderScreen();
|
|
|
|
await screen.findByText('Actions');
|
|
expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull();
|
|
expect(screen.queryByText('Not connected')).toBeNull();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-018: connecting hands off to the provider authorize URL', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
|
|
server.use(http.post('/api/plugin-oauth/p1/connect', () =>
|
|
HttpResponse.json({ authorizeUrl: `${window.location.origin}/#plugin-authorized` })));
|
|
renderScreen();
|
|
|
|
// The card renders for the OAuth section alone, even without fields
|
|
await screen.findByText('Weather Pro');
|
|
expect(screen.getByText('Not connected')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
|
await waitFor(() => expect(window.location.hash).toBe('#plugin-authorized'));
|
|
window.location.hash = '';
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-019: a failed connect toasts and re-enables the button', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { oauth: { configured: true, connected: false } });
|
|
server.use(http.post('/api/plugin-oauth/p1/connect', () => HttpResponse.json({}, { status: 500 })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Not connected');
|
|
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
|
|
|
await screen.findByText('Error');
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).not.toBeDisabled());
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-020: disconnecting flips the section back to "not connected"', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
|
|
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({ connected: false })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Connected');
|
|
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
|
|
|
await screen.findByText('Not connected');
|
|
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-021: a failed disconnect keeps the connected state', async () => {
|
|
const user = userEvent.setup();
|
|
usePluginStore.setState({ plugins: [PLUGIN], loaded: true });
|
|
seedPluginApi('p1', { oauth: { configured: true, connected: true } });
|
|
server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({}, { status: 500 })));
|
|
renderScreen();
|
|
|
|
await screen.findByText('Connected');
|
|
await user.click(screen.getByRole('button', { name: 'Disconnect' }));
|
|
|
|
await screen.findByText('Error');
|
|
expect(screen.getByText('Connected')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-022: a settingsUi plugin gets its own framed card', async () => {
|
|
usePluginStore.setState({ plugins: [{ ...PLUGIN, settingsUi: true }], loaded: true });
|
|
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
|
renderScreen();
|
|
|
|
const frame = await screen.findByTestId('plugin-frame');
|
|
expect(frame).toHaveAttribute('data-plugin', 'p1');
|
|
expect(frame).toHaveAttribute('data-path', 'settings.html');
|
|
expect(frame).toHaveAttribute('data-title', 'Weather Pro');
|
|
// Declared-field form and framed surface are two separate cards
|
|
expect(screen.getAllByText('Weather Pro')).toHaveLength(2);
|
|
});
|
|
|
|
it('FE-MOB-SETPLG-023: several active plugins each get their own form', async () => {
|
|
const second: ActivePlugin = { id: 'p2', name: 'Trip Todos', type: 'widget', icon: null };
|
|
usePluginStore.setState({ plugins: [PLUGIN, second], loaded: true });
|
|
seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } });
|
|
seedPluginApi('p2', { actions: [ACTIONS[0]] });
|
|
renderScreen();
|
|
|
|
await screen.findByText('Weather Pro');
|
|
expect(await screen.findByText('Trip Todos')).toBeInTheDocument();
|
|
expect(screen.queryByText('No plugins are active.')).toBeNull();
|
|
});
|
|
});
|