// FE-PLUGINS-FRAME-001 to 004
import { render, cleanup, waitFor } from '@testing-library/react';
import PluginFrame from './PluginFrame';
const navigate = vi.fn();
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) }));
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
}
afterEach(() => {
cleanup();
navigate.mockClear();
Object.values(toast).forEach((f) => f.mockClear());
invoke.mockClear();
});
describe('PluginFrame', () => {
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html');
const sandbox = iframe.getAttribute('sandbox') || '';
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
});
it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
// message NOT from our iframe -> ignored
window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } }));
expect(navigate).not.toHaveBeenCalled();
// message from our iframe -> handled
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
expect(navigate).toHaveBeenCalledWith('/dashboard');
});
it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative
expect(navigate).not.toHaveBeenCalled();
fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' });
expect(toast.success).toHaveBeenCalledWith('saved');
});
it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
const posted: unknown[] = [];
// capture host->frame messages
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m);
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' });
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
});
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
const posted: Array> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined;
expect(ctx).toBeTruthy();
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
expect(ctx!.formats).toMatchObject({ currency: 'EUR', timeFormat: '24h', distanceUnit: 'metric' });
// Display identity is present but carries NO secret (no email, role only as a boolean).
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
});
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
const { container } = render();
const iframe = container.querySelector('iframe')!;
const posted: Array> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined;
expect(ctx).toBeTruthy();
// A plugin can honour the same accent/density/accessibility choices as the host.
expect(ctx!.appearance).toMatchObject({
scheme: 'default',
density: 'comfortable',
noTransparency: false,
reducedMotion: false,
});
});
});