mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
782e613506
- GPX files drop onto the map or a day; the import lands on that day - wanderer connection per user in Integrations; the workspace offers the user's own trails only, behind an admin switch under the Tours addon - wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled - a GPX or wanderer import can be planned as an editable tour, either in place (the recorded track is kept and can be restored) or as a new tour that remembers the import it came from - rails, rows and detail dialog follow the plan sidebar's cards and badges - admin addon shelves with more than one row fold, collapsed by default - reverse-geocoded waypoint names are cached on the device
941 lines
39 KiB
TypeScript
941 lines
39 KiB
TypeScript
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { delay, http, HttpResponse } from 'msw';
|
|
import { server } from '../../../tests/helpers/msw/server';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
import { useAddonStore } from '../../store/addonStore';
|
|
import { ToastContainer } from '../shared/Toast';
|
|
import AddonManager from './AddonManager';
|
|
|
|
function buildAddon(overrides = {}) {
|
|
return {
|
|
id: 'todo',
|
|
name: 'Todo List',
|
|
description: 'Track tasks',
|
|
icon: 'ListChecks',
|
|
type: 'trip',
|
|
enabled: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
|
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
|
}
|
|
|
|
function llmAddon(config: Record<string, unknown> = {}) {
|
|
return buildAddon({
|
|
id: 'llm_parsing',
|
|
name: 'AI Parsing',
|
|
description: 'Extract bookings from files',
|
|
icon: 'Sparkles',
|
|
type: 'integration',
|
|
enabled: true,
|
|
config,
|
|
});
|
|
}
|
|
|
|
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
|
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
|
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
|
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
|
});
|
|
}
|
|
|
|
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
|
* row alike, so one lookup covers both. */
|
|
function addonToggle(name: string): HTMLElement {
|
|
return screen.getByRole('button', { name });
|
|
}
|
|
|
|
const subToggle = addonToggle;
|
|
|
|
function isOn(toggle: HTMLElement): boolean {
|
|
return toggle.getAttribute('aria-pressed') === 'true';
|
|
}
|
|
|
|
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
|
|
async function unfoldShelves(): Promise<void> {
|
|
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
|
|
for (const bar of bars) await userEvent.click(bar);
|
|
}
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn(() => ({
|
|
matches: false,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
})),
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
|
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
|
server.use(
|
|
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
|
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.documentElement.classList.remove('dark');
|
|
});
|
|
|
|
describe('AddonManager', () => {
|
|
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
return HttpResponse.json({ addons: [] });
|
|
})
|
|
);
|
|
render(<AddonManager />);
|
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
|
render(<AddonManager />);
|
|
expect(await screen.findByText('No addons available')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', name: 'Todo List', type: 'trip' })] })
|
|
)
|
|
);
|
|
render(<AddonManager />);
|
|
await screen.findByText('Todo List');
|
|
// Section header contains "Trip" and "Available as a tab within each trip"
|
|
expect(screen.getAllByText(/Trip/).length).toBeGreaterThan(0);
|
|
expect(screen.getByText(/Available as a tab within each trip/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-004: global and integration sections render when present', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({
|
|
addons: [
|
|
buildAddon({ id: 'global1', name: 'Global Feature', type: 'global' }),
|
|
buildAddon({ id: 'int1', name: 'Integration Feature', type: 'integration' }),
|
|
],
|
|
})
|
|
)
|
|
);
|
|
render(<AddonManager />);
|
|
await screen.findByText('Global Feature');
|
|
expect(screen.getAllByText(/Global/).length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText(/Integration/).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
|
),
|
|
http.put('/api/admin/addons/todo', () =>
|
|
HttpResponse.json({ success: true })
|
|
)
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Todo List');
|
|
|
|
const toggleBtn = addonToggle('Todo List');
|
|
expect(isOn(toggleBtn)).toBe(false);
|
|
|
|
await user.click(toggleBtn);
|
|
|
|
// After click - success toast
|
|
await screen.findByText('Addon updated');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
|
),
|
|
http.put('/api/admin/addons/todo', () =>
|
|
HttpResponse.error()
|
|
)
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Todo List');
|
|
|
|
await user.click(addonToggle('Todo List'));
|
|
|
|
// Error toast appears
|
|
await screen.findByText('Failed to update addon');
|
|
|
|
// The switch is back off after rollback
|
|
await waitFor(() => {
|
|
expect(isOn(addonToggle('Todo List'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-007: bag tracking sub-toggle renders when packing addon is enabled', async () => {
|
|
const user = userEvent.setup();
|
|
const mockToggle = vi.fn();
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
|
)
|
|
);
|
|
render(
|
|
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
|
);
|
|
await screen.findByText('Bag Tracking');
|
|
await user.click(subToggle('Bag Tracking'));
|
|
expect(mockToggle).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-008: bag tracking hidden when packing addon is disabled', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
|
|
)
|
|
);
|
|
render(
|
|
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
|
|
);
|
|
await screen.findByText('Lists');
|
|
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
|
)
|
|
);
|
|
render(<AddonManager bagTrackingEnabled={false} />);
|
|
await screen.findByText('Lists');
|
|
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({
|
|
addons: [
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
|
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
|
|
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
|
|
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
|
|
],
|
|
})
|
|
)
|
|
);
|
|
render(<AddonManager />);
|
|
await unfoldShelves();
|
|
|
|
// Provider sub-rows are visible under Journey addon
|
|
await screen.findByText('Unsplash');
|
|
expect(screen.getByText('Pexels')).toBeInTheDocument();
|
|
|
|
// Journey addon is rendered
|
|
expect(screen.getByText('Journey')).toBeInTheDocument();
|
|
|
|
// Journey's own switch plus one per provider, each labelled with what it controls
|
|
expect(isOn(addonToggle('Journey'))).toBe(true);
|
|
expect(isOn(subToggle('Unsplash'))).toBe(true);
|
|
expect(isOn(subToggle('Pexels'))).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
|
server.use(
|
|
http.get('/api/admin/addons', () =>
|
|
HttpResponse.json({
|
|
addons: [buildAddon({ id: 'mystery', name: 'Mystery Addon', icon: 'NonExistentIcon', type: 'trip' })],
|
|
})
|
|
)
|
|
);
|
|
// Should not throw; Puzzle icon is used as fallback
|
|
expect(() => render(<AddonManager />)).not.toThrow();
|
|
await screen.findByText('Mystery Addon');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
|
|
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('Failed to update addon');
|
|
expect(screen.getByText('No addons available')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
|
|
// The wordmark reads the .dark class, the same source applyAppearance() writes.
|
|
document.documentElement.classList.add('dark');
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('No addons available');
|
|
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
|
|
server.use(addonsRoute([
|
|
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
|
|
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
|
|
buildAddon({ id: 'todo', name: 'Todo List' }),
|
|
]));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Todo List');
|
|
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
|
|
server.use(addonsRoute([
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
|
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
|
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
|
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
|
]));
|
|
render(<AddonManager />);
|
|
await unfoldShelves();
|
|
|
|
await screen.findByText('Immich');
|
|
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
|
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
|
|
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
|
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
|
|
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
|
const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement;
|
|
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
|
|
|
|
expect(isOn(subToggle('Immich'))).toBe(true);
|
|
expect(isOn(subToggle('Unsplash'))).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
|
|
const user = userEvent.setup();
|
|
let body: unknown = null;
|
|
server.use(
|
|
addonsRoute([
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
|
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
|
|
]),
|
|
http.put('/api/admin/addons/immich', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Immich');
|
|
|
|
await user.click(subToggle('Immich'));
|
|
|
|
await waitFor(() => expect(body).toEqual({ enabled: true }));
|
|
await screen.findByText('Addon updated');
|
|
expect(isOn(subToggle('Immich'))).toBe(true);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
addonsRoute([
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
|
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
|
|
]),
|
|
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Unsplash');
|
|
|
|
await user.click(subToggle('Unsplash'));
|
|
|
|
await screen.findByText('Failed to update addon');
|
|
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
|
|
server.use(addonsRoute([
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
|
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
|
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
|
]));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Journey');
|
|
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => {
|
|
const user = userEvent.setup();
|
|
let loads = 0;
|
|
let journeyOn = true;
|
|
let immichOn = true;
|
|
server.use(
|
|
http.get('/api/admin/addons', () => {
|
|
loads += 1;
|
|
return HttpResponse.json({
|
|
addons: [
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
|
|
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
|
|
],
|
|
});
|
|
}),
|
|
http.put('/api/admin/addons/journey', async ({ request }) => {
|
|
journeyOn = (await request.json() as { enabled: boolean }).enabled;
|
|
// Journey off takes its providers with it, see admin.service.ts.
|
|
if (!journeyOn) immichOn = false;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Immich');
|
|
|
|
await user.click(addonToggle('Journey'));
|
|
// The toast closes the whole toggle, re-read included.
|
|
await screen.findByText('Addon updated');
|
|
expect(loads).toBe(2);
|
|
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
|
|
|
await user.click(addonToggle('Journey'));
|
|
|
|
await screen.findByText('Immich');
|
|
expect(isOn(subToggle('Immich'))).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => {
|
|
const user = userEvent.setup();
|
|
let loads = 0;
|
|
server.use(
|
|
http.get('/api/admin/addons', () => {
|
|
loads += 1;
|
|
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
|
|
}),
|
|
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Todo List');
|
|
|
|
await user.click(addonToggle('Todo List'));
|
|
|
|
await screen.findByText('Addon updated');
|
|
expect(loads).toBe(1);
|
|
expect(isOn(addonToggle('Todo List'))).toBe(true);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
|
|
server.use(addonsRoute([
|
|
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
|
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
|
|
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
|
|
]));
|
|
render(<AddonManager />);
|
|
|
|
await unfoldShelves();
|
|
|
|
await screen.findByText('Paperless-ngx');
|
|
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
|
|
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
|
|
expect(isOn(subToggle('Paperless-ngx'))).toBe(true);
|
|
expect(isOn(subToggle('Nextcloud'))).toBe(false);
|
|
// Documents and Journey are the addons; an enabled provider is a shelf row
|
|
// and must not turn 1/2 into 2/2.
|
|
expect(screen.getByText('1/2')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
|
|
server.use(addonsRoute([
|
|
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
|
|
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
|
|
]));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Documents');
|
|
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => {
|
|
const user = userEvent.setup();
|
|
let loads = 0;
|
|
let documentsOn = true;
|
|
let paperlessOn = true;
|
|
server.use(
|
|
http.get('/api/admin/addons', () => {
|
|
loads += 1;
|
|
return HttpResponse.json({
|
|
addons: [
|
|
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }),
|
|
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }),
|
|
],
|
|
});
|
|
}),
|
|
http.put('/api/admin/addons/documents', async ({ request }) => {
|
|
documentsOn = (await request.json() as { enabled: boolean }).enabled;
|
|
if (!documentsOn) paperlessOn = false;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Paperless-ngx');
|
|
|
|
await user.click(addonToggle('Documents'));
|
|
await screen.findByText('Addon updated');
|
|
expect(loads).toBe(2);
|
|
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
|
|
|
|
await user.click(addonToggle('Documents'));
|
|
|
|
await screen.findByText('Paperless-ngx');
|
|
expect(isOn(subToggle('Paperless-ngx'))).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => {
|
|
const user = userEvent.setup();
|
|
let loads = 0;
|
|
let body: unknown = null;
|
|
server.use(
|
|
http.get('/api/admin/addons', () => {
|
|
loads += 1;
|
|
return HttpResponse.json({
|
|
addons: [
|
|
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
|
buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }),
|
|
],
|
|
});
|
|
}),
|
|
http.put('/api/admin/addons/papra', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
await screen.findByText('Papra');
|
|
|
|
await user.click(subToggle('Papra'));
|
|
|
|
await screen.findByText('Addon updated');
|
|
expect(body).toEqual({ enabled: true });
|
|
expect(loads).toBe(1);
|
|
expect(isOn(subToggle('Papra'))).toBe(true);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => {
|
|
const user = userEvent.setup();
|
|
const onToggleCollabFeature = vi.fn();
|
|
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
|
render(
|
|
<AddonManager
|
|
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
|
|
onToggleCollabFeature={onToggleCollabFeature}
|
|
/>,
|
|
);
|
|
|
|
await unfoldShelves();
|
|
|
|
await screen.findByText('Chat');
|
|
expect(screen.getByText('Notes')).toBeInTheDocument();
|
|
expect(screen.getByText('Polls')).toBeInTheDocument();
|
|
expect(screen.getByText("What's Next")).toBeInTheDocument();
|
|
expect(isOn(subToggle('Chat'))).toBe(true);
|
|
expect(isOn(subToggle('Notes'))).toBe(false);
|
|
|
|
await user.click(subToggle('Polls'));
|
|
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
|
|
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Collab');
|
|
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
|
|
// The row's tooltip is its description. The links tab's empty state read as a
|
|
// status here, and a status that never changes reads as a broken feature.
|
|
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
|
render(
|
|
<AddonManager
|
|
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
|
onToggleCollabFeature={vi.fn()}
|
|
/>
|
|
);
|
|
await unfoldShelves();
|
|
|
|
const links = await screen.findByText('Links');
|
|
expect(links).toHaveAttribute('title');
|
|
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
|
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('AI Parsing');
|
|
// The catalog key now exists, so the tile shows the translation rather than falling
|
|
// back to the English description the server sent.
|
|
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
|
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
|
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
|
render(<AddonManager />);
|
|
|
|
const label = await screen.findByText('Provider');
|
|
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
|
const tile = label.closest('article');
|
|
expect(tile).not.toBeNull();
|
|
expect(tile!.textContent).toContain('AI Parsing');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
|
const user = userEvent.setup();
|
|
const urls: (string | null)[] = [];
|
|
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Installed on the server');
|
|
await screen.findByRole('button', { name: 'llama3:8b' });
|
|
expect(urls[0]).toBe('http://localhost:11434/v1');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
|
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
|
|
|
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
|
await user.click(screen.getByRole('button', { name: 'Use' }));
|
|
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
|
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
|
const user = userEvent.setup();
|
|
let calls = 0;
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
http.get('/api/admin/llm/local/models', () => {
|
|
calls += 1;
|
|
return calls === 1
|
|
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
|
: HttpResponse.json({ models: [] });
|
|
}),
|
|
);
|
|
render(<AddonManager />);
|
|
|
|
// The server's reason is shown, not axios' status line (#2649)
|
|
await screen.findByText('down');
|
|
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
await screen.findByText('No models installed yet — pull one below.');
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
|
const user = userEvent.setup();
|
|
const urls: (string | null)[] = [];
|
|
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Installed on the server');
|
|
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
|
|
|
// A hand-typed base URL is used for the next lookup on blur
|
|
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
|
await user.tab();
|
|
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
|
|
|
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
|
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
|
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
|
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
|
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
|
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
|
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
|
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
|
const user = userEvent.setup();
|
|
let pulled: unknown = null;
|
|
let modelCalls = 0;
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
http.get('/api/admin/llm/local/models', () => {
|
|
modelCalls += 1;
|
|
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
|
}),
|
|
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
|
pulled = await request.json();
|
|
await delay(150);
|
|
return new HttpResponse(
|
|
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
|
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
|
);
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('No models installed yet — pull one below.');
|
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
|
|
await screen.findByText('Pulling…');
|
|
expect(screen.getByText('starting…')).toBeInTheDocument();
|
|
|
|
await screen.findByText('Model pulled');
|
|
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
|
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: unknown[] = [];
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
|
modelsRoute([]),
|
|
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
|
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('No models installed yet — pull one below.');
|
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
await screen.findByText('no disk space');
|
|
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Saved');
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Failed to save');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: unknown[] = [];
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
modelsRoute([]),
|
|
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('Installed on the server');
|
|
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
|
|
|
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
|
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
|
|
|
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
|
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Saved');
|
|
// The model is trimmed before it is stored, the key is sent verbatim
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
|
});
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: unknown[] = [];
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
|
modelsRoute([]),
|
|
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('Installed on the server');
|
|
|
|
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
|
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
|
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
|
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Saved');
|
|
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
|
expect(bodies[0]).toEqual({
|
|
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
|
});
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: unknown[] = [];
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
|
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
|
await user.click(await screen.findByRole('button', { name: 'No' }));
|
|
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
await screen.findByText('Saved');
|
|
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
|
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
|
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
|
render(<AddonManager />);
|
|
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
modelsRoute([]),
|
|
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
|
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
|
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
|
)),
|
|
);
|
|
render(<><ToastContainer /><AddonManager /></>);
|
|
|
|
await screen.findByText('No models installed yet — pull one below.');
|
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
|
|
await screen.findByText('manifest not found');
|
|
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
|
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
|
const user = userEvent.setup();
|
|
const urls: (string | null)[] = [];
|
|
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
|
render(<AddonManager />);
|
|
|
|
await screen.findByText('Provider');
|
|
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
|
|
|
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
|
await user.tab();
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
|
expect(urls).toHaveLength(0);
|
|
});
|
|
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
|
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
|
render(
|
|
<AddonManager
|
|
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
|
|
onToggleCollabFeature={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
|
|
expect(bar).toHaveTextContent('2 on');
|
|
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(bar);
|
|
expect(bar).toHaveAttribute('aria-expanded', 'true');
|
|
expect(screen.getByText('Chat')).toBeInTheDocument();
|
|
|
|
await userEvent.click(bar);
|
|
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
|
|
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
|
|
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
|
|
|
|
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
|
|
const puts: unknown[] = [];
|
|
server.use(
|
|
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
|
http.put('/api/admin/tours-wanderer', async ({ request }) => {
|
|
puts.push(await request.json());
|
|
return HttpResponse.json({ enabled: true });
|
|
})
|
|
);
|
|
render(
|
|
<>
|
|
<AddonManager />
|
|
<ToastContainer />
|
|
</>
|
|
);
|
|
|
|
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
|
expect(isOn(toggle)).toBe(false);
|
|
await userEvent.click(toggle);
|
|
|
|
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
|
|
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
|
server.use(
|
|
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
|
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
|
|
);
|
|
render(
|
|
<>
|
|
<AddonManager />
|
|
<ToastContainer />
|
|
</>
|
|
);
|
|
|
|
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
|
|
|
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
|
});
|
|
|
|
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
|
|
server.use(
|
|
addonsRoute([
|
|
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
|
|
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
|
|
])
|
|
);
|
|
render(<AddonManager />);
|
|
|
|
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
|
|
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
|
|
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
|
|
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
|
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
|
});
|
|
});
|