mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
3f49ef8d21
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones e70d7397 fixed, and the discover cards get the press-scale hardening from #2158, on desktop and on the phone panel.
1671 lines
82 KiB
TypeScript
1671 lines
82 KiB
TypeScript
// FE-MOB-PLUGP-001 onwards
|
|
import { http, HttpResponse } from 'msw';
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
|
|
import { resetAllStores } from '../../../helpers/store';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import MAdminPluginsPanel from '../../../../src/mobile/screens/admin/MAdminPluginsPanel';
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
function plugin(over: Row = {}): Row {
|
|
return {
|
|
id: 'trek-gotify',
|
|
name: 'Gotify',
|
|
description: 'Push notifications',
|
|
type: 'integration',
|
|
icon: 'Bell',
|
|
version: '1.0.0',
|
|
status: 'active',
|
|
enabled: 1,
|
|
last_error: null,
|
|
reviewed_at: null,
|
|
source_repo: null,
|
|
permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']),
|
|
capabilities: '{}',
|
|
operatorEgress: false,
|
|
egressHostCount: 0,
|
|
dependencyStatus: 'ok',
|
|
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function registryEntry(over: Row = {}): Row {
|
|
return {
|
|
id: 'trek-gotify',
|
|
name: 'Gotify',
|
|
author: 'Acme',
|
|
description: 'Push notifications for TREK',
|
|
repo: 'acme/gotify',
|
|
type: 'integration',
|
|
latest: '2.0.0',
|
|
// The real browse response always carries latestCompatible (server-side hostCompat);
|
|
// mirror `latest` by default, exactly like a fully-compatible entry.
|
|
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
|
|
minTrekVersion: null,
|
|
reviewedAt: null,
|
|
screenshotUrl: null,
|
|
downloadCount: null,
|
|
signed: true,
|
|
authorPublicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** The two calls every mount makes: the installed list, then (if non-empty) the registry. */
|
|
function mockPanel(plugins: Row[], registry: Row[] = [], extra: Row = {}) {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () =>
|
|
HttpResponse.json({ enabled: true, devLink: false, plugins, ...extra })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
|
|
);
|
|
}
|
|
|
|
let toasts: Array<{ message: string; type: string }> = [];
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
toasts = [];
|
|
// useToast is a thin wrapper around this global; the ToastContainer is never mounted here.
|
|
window.__addToast = ((message: string, type?: string) => {
|
|
toasts.push({ message, type: type ?? 'info' });
|
|
return toasts.length;
|
|
}) as Window['__addToast'];
|
|
// refresh() also nudges the app-wide active-plugin store.
|
|
server.use(http.get('*/api/plugins', () => HttpResponse.json({ plugins: [] })));
|
|
});
|
|
|
|
function toastMessages() {
|
|
return toasts.map(t => t.message);
|
|
}
|
|
|
|
// MSheet binds its Escape listener in an effect, so a key fired the moment the
|
|
// sheet's content appears can land before the listener exists. Retry until the
|
|
// sheet is actually gone rather than pressing once and hoping.
|
|
const escapeUntilGone = (text: string) =>
|
|
waitFor(() => {
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByText(text)).not.toBeInTheDocument();
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — load states', () => {
|
|
it('FE-MOB-PLUGP-001: shows the load error when the plugin list cannot be fetched', async () => {
|
|
server.use(http.get('*/api/admin/plugins', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument();
|
|
// No toolbar behind an error — there is nothing to filter.
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-002: explains that the runtime is off and hides the toolbar', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument();
|
|
expect(screen.queryByText('Runtime on')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-003: an empty install list offers the Discover jump', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('No plugins installed yet.')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /Discover/ }));
|
|
|
|
// The registry only loads once the tab is opened for an empty instance.
|
|
expect(await screen.findByText('Acme')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the installed row', () => {
|
|
it('FE-MOB-PLUGP-004: renders name, version, runtime badge and the capability chips', async () => {
|
|
mockPanel([plugin()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Gotify')).toBeInTheDocument();
|
|
expect(screen.getByText('Runtime on')).toBeInTheDocument();
|
|
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Push notifications')).toBeInTheDocument();
|
|
// Derived from the permission list, not from a separate field.
|
|
expect(screen.getByText('Notification channel')).toBeInTheDocument();
|
|
expect(screen.getByText('gotify.net')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-005: covers the whole capability vocabulary, including a replaced planner tab', async () => {
|
|
mockPanel([plugin({
|
|
permissions: JSON.stringify([
|
|
'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files',
|
|
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta',
|
|
'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider',
|
|
'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider',
|
|
'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe',
|
|
]),
|
|
capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['costs'] } }),
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
for (const label of [
|
|
'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files',
|
|
'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata',
|
|
'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos',
|
|
'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map',
|
|
'Offers routing', 'Adds plan times', 'Reads your position', 'Reacts to activity',
|
|
]) {
|
|
expect(screen.getByText(label)).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-006: read-only costs downgrade the costs chip and a broken permissions blob degrades to none', async () => {
|
|
mockPanel([
|
|
plugin({ id: 'reader', name: 'Reader', permissions: JSON.stringify(['db:read:costs']) }),
|
|
plugin({ id: 'broken', name: 'Broken', permissions: 'not-json', capabilities: 'not-json' }),
|
|
]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Reads your costs')).toBeInTheDocument();
|
|
expect(screen.queryByText('Adds costs')).not.toBeInTheDocument();
|
|
// The unparseable row still renders — a bad blob must not take the panel down.
|
|
expect(screen.getByText('Broken')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-007: an errored plugin shows its last error instead of the chips', async () => {
|
|
mockPanel([plugin({ status: 'error', last_error: 'child exited with code 1' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('child exited with code 1')).toBeInTheDocument();
|
|
expect(screen.queryByText('Notification channel')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-008: dependency chips name the blocker — addon, plugin and TREK range', async () => {
|
|
mockPanel([plugin({
|
|
enabled: 0,
|
|
status: 'inactive',
|
|
dependencyStatus: 'hostIncompatible',
|
|
trekRange: '>=3.2.0 <4.0.0',
|
|
hostVersion: '4.0.0',
|
|
dependencies: { requiredAddons: ['budget'], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] },
|
|
dependencyIssues: { disabledAddons: ['budget'], missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] },
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Needs TREK >=3.2.0 <4.0.0 — this server runs 4.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Requires budget')).toBeInTheDocument();
|
|
expect(screen.getByText('Needs trek-base ^1.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-009: a plugin that never declared a TREK range says so', async () => {
|
|
mockPanel([plugin({ dependencyStatus: 'hostIncompatible', trekRange: null })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-010: the source badge wins over the trust badge', async () => {
|
|
mockPanel([
|
|
plugin({ id: 'a', name: 'Registry one', source_repo: 'acme/gotify', signed: true, reviewed_at: '2026-01-01' }),
|
|
plugin({ id: 'b', name: 'Registry two', source_repo: 'acme/other', signed: false }),
|
|
plugin({ id: 'c', name: 'Uploaded', source_repo: 'local:upload', signed: false }),
|
|
plugin({ id: 'd', name: 'Linked', source_repo: 'local:link', signed: false }),
|
|
]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Registry one');
|
|
expect(screen.getByText('Signed')).toBeInTheDocument();
|
|
// Exactly one Unsigned — the sideloaded and dev-linked rows say something stronger.
|
|
expect(screen.getAllByText('Unsigned')).toHaveLength(1);
|
|
expect(screen.getByText('Sideloaded')).toBeInTheDocument();
|
|
expect(screen.getByText('Dev-Link')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Reviewed')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — toolbar', () => {
|
|
const rows = [
|
|
plugin({ id: 'alpha', name: 'Alpha', description: 'the first', type: 'widget', enabled: 1, status: 'active' }),
|
|
plugin({ id: 'beta', name: 'Beta', description: 'the second', type: 'page', enabled: 0, status: 'inactive' }),
|
|
plugin({ id: 'gamma', name: 'Gamma', description: 'the third', type: 'integration', status: 'error', last_error: 'nope' }),
|
|
];
|
|
|
|
it('FE-MOB-PLUGP-011: the search box filters on name and description', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } });
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'nothing here' } });
|
|
expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-012: the type sheet narrows the list to one plugin type', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.click(screen.getByTitle('Type: All types'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Page' }));
|
|
|
|
await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Type: Page')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-013: the status sheet separates on, off and errored plugins', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.click(screen.getByTitle('Status: All'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Off' }));
|
|
await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTitle('Status: Off'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Error' }));
|
|
await waitFor(() => expect(screen.queryByText('Beta')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Gamma')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTitle('Status: Error'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Active' }));
|
|
await waitFor(() => expect(screen.queryByText('Gamma')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-014: "Updates first" ranks the updatable plugin above an alphabetically earlier one', async () => {
|
|
mockPanel(
|
|
[
|
|
plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }),
|
|
plugin({ id: 'zulu', name: 'Zulu', version: '1.0.0' }),
|
|
],
|
|
[registryEntry({ id: 'zulu', name: 'Zulu', latest: '2.0.0' }), registryEntry({ id: 'alpha', name: 'Alpha', latest: '1.0.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('1 updates available for your plugins.');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Updates first' }));
|
|
|
|
await waitFor(() => {
|
|
const names = screen.getAllByText(/^(Alpha|Zulu)$/).map(n => n.textContent);
|
|
expect(names).toEqual(['Zulu', 'Alpha']);
|
|
});
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-015: the "update available" status filter keeps only the plugin with an update', async () => {
|
|
mockPanel(
|
|
[plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }), plugin({ id: 'zulu', name: 'Zulu', version: '9.0.0' })],
|
|
[registryEntry({ id: 'alpha', name: 'Alpha', latest: '2.0.0' }), registryEntry({ id: 'zulu', name: 'Zulu', latest: '9.0.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('1 updates available for your plugins.');
|
|
|
|
fireEvent.click(screen.getByTitle('Status: All'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update available' }));
|
|
|
|
await waitFor(() => expect(screen.queryByText('Zulu')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — Discover', () => {
|
|
async function openDiscover(entries: Row[]) {
|
|
mockPanel([], entries);
|
|
const view = render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
return view;
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-016: a registry card carries author, type, version, downloads and the reviewed ribbon', async () => {
|
|
await openDiscover([registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 1234, screenshotUrl: null })]);
|
|
|
|
expect(await screen.findByText('Acme')).toBeInTheDocument();
|
|
expect(screen.getByText('Integration')).toBeInTheDocument();
|
|
expect(screen.getByText('v2.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('1.2k')).toBeInTheDocument();
|
|
expect(screen.getAllByText('Reviewed').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-017: installing from a card posts the id and reports success', async () => {
|
|
let body: unknown = null;
|
|
await openDiscover([registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Installed'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => {
|
|
await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]);
|
|
|
|
const btn = await screen.findByRole('button', { name: 'Incompatible' });
|
|
expect(btn).toBeDisabled();
|
|
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => {
|
|
let body: unknown = null;
|
|
await openDiscover([registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })]);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Install 1.5.0' }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-020: an already-installed plugin cannot be installed twice', async () => {
|
|
mockPanel([plugin()], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
|
|
const btn = await screen.findByRole('button', { name: 'Installed' });
|
|
expect(btn).toBeDisabled();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-021: an empty registry and a filtered-to-nothing registry read differently', async () => {
|
|
await openDiscover([]);
|
|
expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-022: a search with no registry hit says so', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
await screen.findByText('Acme');
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzz' } });
|
|
expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-023: an unreachable registry degrades to an empty list, not a crash', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: /Discover/ }));
|
|
|
|
expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-024: Discover sorts by downloads and by review date', async () => {
|
|
await openDiscover([
|
|
registryEntry({ id: 'a', name: 'Aaa', downloadCount: 5, reviewedAt: '2026-01-01T00:00:00Z' }),
|
|
registryEntry({ id: 'z', name: 'Zzz', downloadCount: 900, reviewedAt: '2026-06-01T00:00:00Z' }),
|
|
]);
|
|
await screen.findByText('Aaa');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']);
|
|
});
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Most downloads'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Recently updated' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']);
|
|
});
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-025: switching back to Installed drops a sort key that tab cannot offer', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
await screen.findByText('Acme');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Most downloads')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: /Installed/ }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-026: a broken screenshot falls back to the placeholder tile', async () => {
|
|
const { container } = await openDiscover([registryEntry({ screenshotUrl: 'https://example.test/shot.png' })]);
|
|
|
|
await screen.findByText('Acme');
|
|
const img = container.querySelector('img') as HTMLImageElement;
|
|
expect(img).toHaveAttribute('src', 'https://example.test/shot.png');
|
|
|
|
fireEvent.error(img);
|
|
await waitFor(() => expect(container.querySelector('img')).toBeNull());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-027: the card opens the detail sheet from the keyboard too', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () =>
|
|
HttpResponse.json({ ...registryEntry(), size: null, publishedAt: null, manifest: null })));
|
|
|
|
const card = (await screen.findByText('Acme')).closest('[role="button"]') as HTMLElement;
|
|
fireEvent.keyDown(card, { key: 'Enter' });
|
|
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-093: the card opts out of the global press-scale (#2158)', async () => {
|
|
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
|
// the card shifted the Install button out from under the pointer, so the tap
|
|
// retargeted onto the card and opened the detail sheet instead of installing.
|
|
// The data-no-press attribute is the pin.
|
|
await openDiscover([registryEntry()]);
|
|
|
|
expect((await screen.findByText('Acme')).closest('[role="button"]')).toHaveAttribute('data-no-press');
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the registry detail sheet', () => {
|
|
const manifest = {
|
|
permissions: ['db:own', 'db:read:trips', 'http:outbound:gotify.net'],
|
|
egress: ['gotify.net'],
|
|
operatorEgress: true,
|
|
settings: [{ key: 'token', label: 'API token', inputType: 'password', scope: 'instance', required: true }],
|
|
license: 'MIT',
|
|
icon: 'Bell',
|
|
};
|
|
|
|
async function openDetail(detail: Row, entry: Row = registryEntry()) {
|
|
mockPanel([], [entry]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detail)));
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
return screen.findByRole('dialog');
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-028: lists access, outbound hosts, the operator caveat, setup fields and the meta grid', async () => {
|
|
const entry = registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 4200, trek: '>=3.2.0 <4.0.0' });
|
|
await openDetail({ ...entry, size: 4096, publishedAt: null, manifest }, entry);
|
|
|
|
expect(await screen.findByText('What it can access')).toBeInTheDocument();
|
|
expect(screen.getByText('Reads your trips')).toBeInTheDocument();
|
|
expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument();
|
|
expect(screen.getByText('gotify.net')).toBeInTheDocument();
|
|
expect(screen.getByText('+ hosts you add')).toBeInTheDocument();
|
|
expect(screen.getByText('API token')).toBeInTheDocument();
|
|
expect(screen.getByText('Instance-wide')).toBeInTheDocument();
|
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
expect(screen.getByText('4 KB')).toBeInTheDocument();
|
|
expect(screen.getByText('TREK >=3.2.0 <4.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('4,200')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-029: a plugin that needs nothing says so, and falls back to the min-version line', async () => {
|
|
await openDetail(
|
|
{ ...registryEntry({ minTrekVersion: '3.0.0' }), size: null, publishedAt: null,
|
|
manifest: { permissions: [], egress: [], settings: [], license: null, icon: null } },
|
|
registryEntry({ minTrekVersion: '3.0.0' }),
|
|
);
|
|
|
|
expect(await screen.findByText('Needs no special access.')).toBeInTheDocument();
|
|
expect(screen.getByText('TREK 3.0.0+')).toBeInTheDocument();
|
|
expect(screen.queryByText('Connects to')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-092: the manifest capabilities are chipped before the install decision', async () => {
|
|
await openDetail({
|
|
...registryEntry(),
|
|
size: null,
|
|
publishedAt: null,
|
|
manifest: {
|
|
...manifest,
|
|
capabilities: { widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } },
|
|
},
|
|
});
|
|
|
|
expect(await screen.findByText('Replaces planner tabs')).toBeInTheDocument();
|
|
expect(screen.getByText('Boarding-pass widget')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-030: a failed detail fetch is reported inside the sheet', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
|
|
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-031: the blocked reason is spelled out in the sheet, not just as a tooltip', async () => {
|
|
const entry = registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
|
|
await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry);
|
|
|
|
expect(await screen.findAllByText('Needs TREK >=4.0.0 — this server runs 3.3.0')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-032: a distinct homepage gets its own link, and the sheet closes again', async () => {
|
|
await openDetail(
|
|
{ ...registryEntry({ homepage: 'https://gotify.example' }), size: null, publishedAt: null, manifest: null },
|
|
registryEntry({ homepage: 'https://gotify.example' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
expect(within(dialog).getByRole('link', { name: /Homepage/ })).toHaveAttribute('href', 'https://gotify.example');
|
|
expect(within(dialog).getByRole('link', { name: /Source repository/ })).toHaveAttribute('href', 'https://github.com/acme/gotify');
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-033: installing straight from the sheet uses the offered version', async () => {
|
|
let body: unknown = null;
|
|
const entry = registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' });
|
|
await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Install 1.5.0' }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the row action sheet', () => {
|
|
async function openRowMenu(over: Row = {}) {
|
|
mockPanel([plugin(over)]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
return screen.findByRole('dialog');
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-CFG-005: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
|
|
// An admin must never be invited to widen egress for a plugin that didn't ask for it
|
|
// — same rule the row's egress chip already follows. (Fixture default: operatorEgress false.)
|
|
const dialog = await openRowMenu();
|
|
|
|
expect(within(dialog).getByText('View error log')).toBeInTheDocument(); // sheet is open
|
|
expect(within(dialog).queryByText('Allowed hosts')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-034: a registry plugin offers every action plus the repository links', async () => {
|
|
const dialog = await openRowMenu({ source_repo: 'acme/gotify', operatorEgress: true });
|
|
|
|
for (const label of ['Restart', 'View error log', 'Allowed hosts', 'Source repository', 'Report an issue', 'Delete']) {
|
|
expect(within(dialog).getByText(label)).toBeInTheDocument();
|
|
}
|
|
expect(within(dialog).getByRole('link', { name: /Report an issue/ }))
|
|
.toHaveAttribute('href', 'https://github.com/acme/gotify/issues');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-035: a sideloaded, disabled plugin gets neither Restart nor repo links', async () => {
|
|
const dialog = await openRowMenu({ source_repo: 'local:upload', enabled: 0, status: 'inactive' });
|
|
|
|
expect(within(dialog).queryByText('Restart')).not.toBeInTheDocument();
|
|
expect(within(dialog).queryByText('Source repository')).not.toBeInTheDocument();
|
|
expect(within(dialog).getByText('Delete')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-036: Restart cycles the plugin off and on', async () => {
|
|
const calls: string[] = [];
|
|
await openRowMenu();
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { calls.push('deactivate'); return HttpResponse.json({ ok: true }); }),
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () => { calls.push('activate'); return HttpResponse.json({ ok: true }); }),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Restart'));
|
|
await waitFor(() => expect(calls).toEqual(['deactivate', 'activate']));
|
|
expect(toastMessages()).toContain('Plugin restarted');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-037: the error log sheet lists the recorded lines', async () => {
|
|
await openRowMenu();
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () =>
|
|
HttpResponse.json({ errors: [{ ts: '2026-07-01T10:00:00Z', level: 'error', message: 'ECONNREFUSED' }] })));
|
|
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
expect(await screen.findByText('ECONNREFUSED')).toBeInTheDocument();
|
|
expect(screen.getByText('error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-038: a failing error-log fetch still opens the sheet, empty', async () => {
|
|
await openRowMenu();
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
expect(await screen.findByText('No errors logged.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-039: Delete asks first, then uninstalls with its data', async () => {
|
|
let body: unknown = null;
|
|
await openRowMenu();
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ deleteData: true }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin uninstalled'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-040: cancelling the uninstall confirmation calls nothing', async () => {
|
|
let called = false;
|
|
await openRowMenu();
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', () => { called = true; return HttpResponse.json({ ok: true }); }));
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByText('Uninstall plugin?')).not.toBeInTheDocument());
|
|
expect(called).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — operator-supplied egress hosts', () => {
|
|
it('FE-MOB-PLUGP-041: the chip invites a first host and turns into a count once one exists', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
const { unmount } = render(<MAdminPluginsPanel />);
|
|
expect(await screen.findByRole('button', { name: 'Add allowed host' })).toBeInTheDocument();
|
|
unmount();
|
|
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 2 })]);
|
|
render(<MAdminPluginsPanel />);
|
|
expect(await screen.findByRole('button', { name: '2 allowed host(s)' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-042: the sheet adds and removes hosts through the API', async () => {
|
|
const bodies: unknown[] = [];
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 1 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.test'] })),
|
|
http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => {
|
|
const body = await request.json() as { hosts: string[] };
|
|
bodies.push(body);
|
|
return HttpResponse.json({ hosts: body.hosts });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '1 allowed host(s)' }));
|
|
expect(await screen.findByText('gotify.mydomain.test')).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: ' ntfy.mydomain.test ' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
|
await waitFor(() => expect(bodies[0]).toEqual({ hosts: ['gotify.mydomain.test', 'ntfy.mydomain.test'] }));
|
|
|
|
fireEvent.click((await screen.findAllByRole('button', { name: 'Delete' }))[0]);
|
|
await waitFor(() => expect(bodies[1]).toEqual({ hosts: ['ntfy.mydomain.test'] }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-043: a rejected save surfaces the server message and keeps the sheet open', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })),
|
|
http.put('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ error: 'not a hostname' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'not a host' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
|
expect(await screen.findByText('not a hostname')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-044: a plugin with fixed manifest hosts says the sheet does not apply', async () => {
|
|
// Declares operatorEgress (so the menu item shows), but the runtime lookup
|
|
// disagrees — the sheet's unsupported notice is the truth-teller.
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: false, hosts: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('Allowed hosts'));
|
|
expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-045: a failing hosts fetch degrades to the unsupported notice', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — updates and consent', () => {
|
|
it('FE-MOB-PLUGP-046: the updates bar updates every outdated plugin at once', async () => {
|
|
const updated: string[] = [];
|
|
mockPanel(
|
|
[plugin({ id: 'a', name: 'Aaa', version: '1.0.0' }), plugin({ id: 'b', name: 'Bbb', version: '1.0.0' })],
|
|
[registryEntry({ id: 'a', name: 'Aaa', latest: '2.0.0' }), registryEntry({ id: 'b', name: 'Bbb', latest: '1.1.0' })],
|
|
);
|
|
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
|
|
updated.push(String(params.id));
|
|
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update all' }));
|
|
await waitFor(() => expect(updated.sort()).toEqual(['a', 'b']));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin updated'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046a: an update this TREK cannot install is neither offered nor counted', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046b: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046f: a held plugin leaves the banner and offers Resume updates instead', async () => {
|
|
let resumed = false;
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0', updateHold: true })],
|
|
[registryEntry({ latest: '2.0.0' })],
|
|
);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
|
|
resumed = true;
|
|
return HttpResponse.json({ updateHold: false });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
|
|
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /resume updates/i }));
|
|
await waitFor(() => expect(resumed).toBe(true));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046d: the detail sheet lists every version and installs the picked one', async () => {
|
|
const bodies: unknown[] = [];
|
|
mockPanel([], [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
|
|
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
|
|
size: 1024, publishedAt: null, manifest: null,
|
|
versions: [
|
|
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
|
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
],
|
|
})),
|
|
http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
|
|
// The incompatible latest is explained, never offered.
|
|
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }));
|
|
await waitFor(() => expect(bodies).toEqual([{ id: 'trek-gotify', version: '1.5.0' }]));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046e: Change version rolls back through update, after an explicit data warning', async () => {
|
|
let body: unknown = null;
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '2.0.0' })],
|
|
[registryEntry()],
|
|
);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
|
|
...registryEntry(), size: 1024, publishedAt: null, manifest: null,
|
|
versions: [
|
|
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
],
|
|
})),
|
|
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
|
|
|
|
// The installed version is marked, not switchable.
|
|
const current = await screen.findByTestId('version-row-2.0.0');
|
|
expect(within(current).getByText('Installed')).toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }));
|
|
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /roll back/i }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046c: the update offer is the newest compatible version, not the absolute latest', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-047: a per-row update that widens rights opens the consent sheet and approving activates with consent', async () => {
|
|
let consentBody: unknown = null;
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', signed: true, version: '1.0.0' })], [registryEntry()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips', 'x:unknown'], newEgress: ['api.acme.test'] })),
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => {
|
|
consentBody = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
// A known permission reads as prose, an unknown one as its raw code.
|
|
expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument();
|
|
expect(screen.getByText('x:unknown')).toBeInTheDocument();
|
|
expect(screen.getByText('api.acme.test')).toBeInTheDocument();
|
|
// The registry entry is signed, so no unsigned caveat.
|
|
expect(screen.queryByText(/Nothing ties this version to its author/)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Approve & turn on' }));
|
|
await waitFor(() => expect(consentBody).toEqual({ consent: true }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-048: "Keep off for now" drops the prompt and says the update stays off', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Keep off for now' }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Update installed — left off until you approve the new permissions'));
|
|
await waitFor(() => expect(screen.queryByText('This update needs new permissions')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-049: an unsigned update says nothing ties it to its author', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', signed: false, version: '1.0.0' })], [registryEntry({ signed: false })]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText(/Nothing ties this version to its author/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-050: a failed update reports the server message', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('registry unreachable'));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — signature refusals', () => {
|
|
const blocked = (code: string) => plugin({
|
|
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0',
|
|
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-051: the row keeps showing why the update was refused', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Update blocked — the signing key changed')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-052: the block goes quiet once the registry offers a newer version', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry({ latest: '3.0.0' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
await waitFor(() => expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-053: a sideloaded plugin never shows an update block', async () => {
|
|
mockPanel([plugin({ source_repo: 'local:upload', updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' } })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Sideloaded');
|
|
expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-054: Review compares both fingerprints and the confirmation re-pins and updates in one call', async () => {
|
|
let body: unknown = null;
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText('Key it was installed with')).toBeInTheDocument();
|
|
expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
|
|
// Fingerprint of the registry's new key: head…tail of the base64 payload.
|
|
expect(screen.getByText('RWQ12345…87654321')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Trust the new key & update' }));
|
|
await waitFor(() => expect(body).toEqual({
|
|
version: '2.0.0',
|
|
publicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321',
|
|
}));
|
|
await waitFor(() => expect(toastMessages()).toContain('New signing key trusted — the plugin is updated'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-055: a re-trust whose update widens rights still asks for consent', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' }));
|
|
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-056: a rejected re-trust reports the server message', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
|
|
HttpResponse.json({ error: 'key changed again' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('key changed again'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-057: an invalid signature is explained and offers no override at all', async () => {
|
|
mockPanel([blocked('SIGNATURE_INVALID')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/do not match the author's signature/i)).toBeInTheDocument();
|
|
// The raw server detail is shown instead of a key comparison that would imply a choice.
|
|
expect(screen.getByText('the signing key changed')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Key it is offering now')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-058: a missing and a half-signed signature each get their own explanation', async () => {
|
|
mockPanel([blocked('SIGNATURE_MISSING')], [registryEntry()]);
|
|
const { unmount } = render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/ships no signature/i)).toBeInTheDocument();
|
|
unmount();
|
|
|
|
mockPanel([blocked('SIGNATURE_INCOMPLETE')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/half-signed/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-059: an update refused on its signature opens the dialog instead of a toast', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDKEYaa…aaaaaaaa' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ error: 'signing key changed', code: 'SIGNATURE_KEY_CHANGED' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument();
|
|
expect(toastMessages()).not.toContain('signing key changed');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-060: a fresh install refused for an invalid signature names the plugin from the registry entry', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/install', () =>
|
|
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
|
|
|
|
expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-061: the dialog closes again without re-trusting', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Do not trust it' }));
|
|
await waitFor(() => expect(screen.queryByText('Key it was installed with')).not.toBeInTheDocument());
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — enabling a plugin', () => {
|
|
const off = (over: Row = {}) => plugin({ enabled: 0, status: 'inactive', ...over });
|
|
|
|
it('FE-MOB-PLUGP-062: the toggle turns an active plugin off', async () => {
|
|
let called = false;
|
|
mockPanel([plugin()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }); }));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(called).toBe(true));
|
|
expect(toastMessages()).toContain('Plugin deactivated');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-063: enabling a plugin also reports the dependencies it switched on for you', async () => {
|
|
// Named so the parent sorts first — the rows are ordered by name, and the toggle is picked by position.
|
|
mockPanel([
|
|
off({ dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] } }),
|
|
plugin({ id: 'trek-base', name: 'Zzz Base', enabled: 0, status: 'inactive' }),
|
|
]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
fireEvent.click(screen.getAllByRole('switch', { name: 'Enable plugin' })[0]);
|
|
await waitFor(() => expect(toastMessages()).toContain('Enabled required plugin(s) first: Zzz Base'));
|
|
expect(toastMessages()).toContain('Plugin activated');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-064: a 409 CONSENT_REQUIRED routes to the consent sheet', async () => {
|
|
mockPanel([off({ source_repo: 'acme/gotify', signed: false })], []);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
// The registry is empty here, so the caveat falls back to the installed row's own flag.
|
|
expect(screen.getByText(/Nothing ties this version to its author/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-065: a 409 ADDON_DISABLED names the addons to switch on first', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'addon disabled', code: 'ADDON_DISABLED', addons: ['budget', 'vacay'] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Enable the required addon(s) first: budget, vacay'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-066: any other activation failure surfaces the server message', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'dependency cycle', code: 'DEPENDENCY_CYCLE' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('dependency cycle'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-067: a 409 DEPENDENCY_MISSING opens the resolve sheet, downloads and retries', async () => {
|
|
let installBody: unknown = null;
|
|
let activateCalls = 0;
|
|
mockPanel([off()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () => {
|
|
activateCalls += 1;
|
|
if (activateCalls === 1) {
|
|
return HttpResponse.json({
|
|
error: 'missing dependency', code: 'DEPENDENCY_MISSING',
|
|
missing: [{ id: 'trek-base', version: '^1.0.0' }],
|
|
versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }],
|
|
}, { status: 409 });
|
|
}
|
|
return HttpResponse.json({ ok: true });
|
|
}),
|
|
http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
installBody = await request.json();
|
|
return HttpResponse.json({ installed: ['trek-base'], requiredAddons: ['budget'] });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
expect(await screen.findByText('Missing dependencies')).toBeInTheDocument();
|
|
expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument();
|
|
expect(screen.getByText('Downloads the latest compatible version, including its own dependencies.')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Download/ }));
|
|
await waitFor(() => expect(installBody).toEqual({ id: 'trek-base', constraint: '^1.0.0', withDependencies: true }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Downloaded trek-base'));
|
|
// The dependency install revealed a disabled addon on top.
|
|
expect(toastMessages()).toContain('Enable the required addon(s) first: budget');
|
|
await waitFor(() => expect(activateCalls).toBe(2));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-068: a signature refusal while downloading a dependency names the dependency', async () => {
|
|
mockPanel([off()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
|
http.post('*/api/admin/plugins/install', () =>
|
|
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Download/ }));
|
|
|
|
expect(await screen.findByText("trek-base's signature could not be verified")).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-069: the resolve sheet can be dismissed without downloading', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument());
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — sideloading, dev-link and rescan', () => {
|
|
it('FE-MOB-PLUGP-070: the toolbar upload button installs the picked archive', async () => {
|
|
let uploaded = false;
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-new' }); }));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Upload plugin' }));
|
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip', { type: 'application/zip' })] } });
|
|
|
|
await waitFor(() => expect(uploaded).toBe(true));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin “trek-new” uploaded — activate it to run'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-071: a rejected upload reports the server message', async () => {
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () =>
|
|
HttpResponse.json({ error: 'manifest missing' }, { status: 400 })));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip')] } });
|
|
await waitFor(() => expect(toastMessages()).toContain('manifest missing'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-072: dragging an archive over the panel offers to install it, and dropping does', async () => {
|
|
let uploaded = false;
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'dropped' }); }));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
const file = new File(['zip'], 'plugin.zip');
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [file] } });
|
|
expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument();
|
|
|
|
fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [file] } });
|
|
await waitFor(() => expect(uploaded).toBe(true));
|
|
await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-073: dragging something that is not a file never arms the overlay', async () => {
|
|
mockPanel([]);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['text/plain'], files: [] } });
|
|
expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-074: leaving the panel again disarms the overlay', async () => {
|
|
mockPanel([]);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
await screen.findByText('Drop a plugin .zip to install');
|
|
fireEvent.dragOver(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
fireEvent.dragLeave(panel);
|
|
await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-075: the dev-link form registers a local build directory', async () => {
|
|
let body: unknown = null;
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])),
|
|
http.post('*/api/admin/plugins/link', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ id: 'local-plugin' });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin');
|
|
// An empty path is a no-op, not a request.
|
|
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
|
expect(body).toBeNull();
|
|
|
|
fireEvent.change(input, { target: { value: '/srv/plugins/local' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^Link$/ }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ path: '/srv/plugins/local' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Linked local-plugin — activate it to run'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-076: a rejected dev-link reports the server message', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])),
|
|
http.post('*/api/admin/plugins/link', () => HttpResponse.json({ error: 'no manifest there' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin');
|
|
fireEvent.change(input, { target: { value: '/nope' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^Link$/ }));
|
|
await waitFor(() => expect(toastMessages()).toContain('no manifest there'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-077: Rescan rediscovers local plugins and force-pulls the registry', async () => {
|
|
let rescanned = false;
|
|
let refreshParam: string | null = null;
|
|
mockPanel([]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/rescan', () => { rescanned = true; return HttpResponse.json({ ok: true }); }),
|
|
http.get('*/api/admin/plugins/registry', ({ request }) => {
|
|
refreshParam = new URL(request.url).searchParams.get('refresh');
|
|
return HttpResponse.json([registryEntry()]);
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Rescan' }));
|
|
await waitFor(() => expect(rescanned).toBe(true));
|
|
await waitFor(() => expect(refreshParam).toBe('1'));
|
|
expect(toastMessages()).toContain('Rescanned the plugins folder');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-078: a failed rescan falls back to the generic action error', async () => {
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({}, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Rescan' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Action failed'));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — edge paths', () => {
|
|
it('FE-MOB-PLUGP-080: a block stands while the registry is unreachable, and offers no override without the new key', async () => {
|
|
mockPanel([plugin({
|
|
source_repo: 'acme/gotify', signed: true, keyFingerprint: null, version: '1.0.0',
|
|
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
|
|
})], []);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
// Nothing proves the block stale, so it must not be dropped.
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
// Neither fingerprint is knowable: no pinned key, and no registry entry to read the new one from.
|
|
expect(await screen.findAllByText('—')).toHaveLength(2);
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-081: a key with no payload line has no fingerprint to show', async () => {
|
|
mockPanel([plugin({
|
|
source_repo: 'acme/gotify', keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0',
|
|
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' },
|
|
})], [registryEntry({ authorPublicKey: 'untrusted comment: minisign public key' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText('—')).toBeInTheDocument();
|
|
expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-082: an outdated dependency is flagged amber just like a missing one', async () => {
|
|
mockPanel([plugin({
|
|
dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^2.0.0' }] },
|
|
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [{ id: 'trek-base', wanted: '^2.0.0', installed: '1.0.0' }] },
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Needs trek-base ^2.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-083: switching to Discover drops the installed-only sort key', async () => {
|
|
mockPanel([plugin()], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Updates first' }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Updates first')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: /Discover/ }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-084: dropping an archive while the runtime is off installs nothing', async () => {
|
|
let uploaded = false;
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })),
|
|
http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }); }),
|
|
);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Plugins are disabled');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument();
|
|
|
|
fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [new File(['zip'], 'p.zip')] } });
|
|
await waitFor(() => expect(screen.getByText('Plugins are disabled')).toBeInTheDocument());
|
|
expect(uploaded).toBe(false);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-085: an ordinary failure downloading a dependency falls back to the activation error', async () => {
|
|
mockPanel([plugin({ enabled: 0, status: 'inactive' })]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
|
http.post('*/api/admin/plugins/install', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Download/ }));
|
|
await waitFor(() => expect(toastMessages()).toContain('registry unreachable'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-086: every sheet closes from its own close button and from Escape', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true, egressHostCount: 1 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] })),
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
// Row action sheet → its own close button.
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('View error log')).not.toBeInTheDocument());
|
|
|
|
// Error log → Escape, then the close button.
|
|
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
await screen.findByText('No errors logged.');
|
|
await escapeUntilGone('No errors logged.');
|
|
|
|
// Allowed hosts → Escape, from the row chip.
|
|
fireEvent.click(screen.getByRole('button', { name: '1 allowed host(s)' }));
|
|
await screen.findByText('No hosts added yet.');
|
|
await escapeUntilGone('No hosts added yet.');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-087: the hosts sheet closes from its own close button too', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: true, hosts: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
await screen.findByText('No hosts added yet.');
|
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('No hosts added yet.')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-088: the error log sheet closes from its own close button too', async () => {
|
|
mockPanel([plugin()]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
await screen.findByText('No errors logged.');
|
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('No errors logged.')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-090: an unreachable registry behind an installed list costs only the update badges', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [plugin({ version: '1.0.0' })] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Gotify')).toBeInTheDocument();
|
|
expect(screen.queryByText(/updates available/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-091: a second tap on the toggle while the first is in flight is ignored', async () => {
|
|
let calls = 0;
|
|
let release: () => void = () => {};
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
mockPanel([plugin({ enabled: 0, status: 'inactive' })]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async () => {
|
|
calls += 1;
|
|
await gate;
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const toggle = await screen.findByRole('switch', { name: 'Enable plugin' });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(toggle);
|
|
release();
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin activated'));
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-089: six-figure download counts round into millions', async () => {
|
|
mockPanel([], [registryEntry({ downloadCount: 1_200_000 })]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
|
|
expect(await screen.findByText('1.2M')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the security footer', () => {
|
|
it('FE-MOB-PLUGP-079: expands the containment explainer on demand', async () => {
|
|
mockPanel([]);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByText('How plugins are contained — and the limits'));
|
|
|
|
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument();
|
|
expect(screen.getByText('The worst case')).toBeInTheDocument();
|
|
expect(screen.getByText('What "Signed" means')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('How plugins are contained — and the limits'));
|
|
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The admin-owned `scope:'instance'` settings sheet — the phone counterpart of the
|
|
* desktop modal. Same contract: gated on declared instance fields, secrets stay
|
|
* write-only (an untouched mask is never sent), and a save that restarted the
|
|
* running plugin must say so.
|
|
*/
|
|
describe('MAdminPluginsPanel — instance settings', () => {
|
|
const FIELDS = [
|
|
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
|
|
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
|
|
];
|
|
|
|
async function openRowSheet(p: Row) {
|
|
mockPanel([p]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
}
|
|
|
|
async function openSettings() {
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' } })),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 2 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 });
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-CFG-001: a plugin with instance fields offers the sheet action, opening the form', async () => {
|
|
await openSettings();
|
|
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-002: a plugin with NO instance fields gets no action', async () => {
|
|
await openRowSheet(plugin({ instanceSettingsCount: 0 }));
|
|
expect(screen.getByText('View error log')).toBeInTheDocument(); // the sheet is open
|
|
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
|
body = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ config: body, restarted: false });
|
|
}),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 });
|
|
expect(body).toEqual({ apiUrl: 'https://new.example' });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-004: a save that restarted the running plugin says so', async () => {
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved — plugin restarted'), { timeout: 5000 });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-006: renders every declared field type and saves the edited values', async () => {
|
|
const RICH = [
|
|
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
|
|
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
|
|
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
|
|
];
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
|
|
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
|
body = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ config: body, restarted: false });
|
|
}),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 3 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
|
|
const select = await screen.findByRole('combobox', {}, { timeout: 5000 });
|
|
expect(select).toHaveValue('slow');
|
|
expect(screen.getByRole('switch', { name: 'enabled' })).toBeInTheDocument(); // label falls back to the key
|
|
expect(screen.getByRole('spinbutton')).toHaveValue(null);
|
|
expect(screen.getByText('Pick one')).toBeInTheDocument(); // hint
|
|
|
|
fireEvent.change(select, { target: { value: 'fast' } });
|
|
fireEvent.click(screen.getByRole('switch', { name: 'enabled' }));
|
|
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 });
|
|
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-007: a failed config fetch is a toast, not a broken sheet', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 2 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Error'), { timeout: 5000 });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-008: a rejected save shows the server reason and keeps the sheet open', async () => {
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument();
|
|
// Still open — the admin's edits are not thrown away on a refusal.
|
|
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-009: the sheet closes without saving', async () => {
|
|
await openSettings();
|
|
|
|
// Same retry shape as escapeUntilGone, but on the input value — an input's
|
|
// value is not text content, so the text-based helper would pass vacuously.
|
|
await waitFor(() => {
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|