feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall

- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens,
  Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt),
  XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot
- xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy),
  Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber
  key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus
  damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot
- xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird
  mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter
  Hinweis auf verweigertes Einbetten; 8 Tests
- Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol),
  Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist
  "neuem"; der Server ruft die Adresse nie ab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:18:48 +02:00
parent 8bf3601a18
commit d63d9f5563
18 changed files with 904 additions and 7 deletions
@@ -0,0 +1,153 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// next-intl — Durchreiche t(key) => key (Muster picture-frame-widget.test.tsx)
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import { XFRAME_SANDBOX } from './xframe-config';
import { XframeWidget } from './xframe-widget';
/**
* xframe-widget.test — NEU (quick-260921-qd3).
*
* Kachel: Leerzustand (auch bei http), genau ein <iframe> mit exakter
* Sandbox (T-QD3-01), no-referrer (T-QD3-05), lazy, leerem allow
* (T-QD3-06); Link „In neuem Tab öffnen“ als Ecksymbol oder in der
* Kopfleiste; Titel wird escaped (T-QD3-09); Neuladen per Timer haengt den
* Rahmen neu ein und raeumt beim Aushaengen (T-QD3-08); Bearbeitungs-Overlay
* nach dem Rahmen im DOM (T-QD3-07).
*
* jsdom laedt keine Unterressourcen — ein <iframe src="https://…"> erzeugt
* hier keinen Netzabruf.
*/
const URL_A = 'https://intern.example/board';
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
return render(<XframeWidget instanceId="xf-1" config={config} isEditMode={isEditMode} />);
}
function frame(): HTMLIFrameElement {
return screen.getByTestId('xframe-frame') as HTMLIFrameElement;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('XframeWidget (quick-260921-qd3)', () => {
it('Test 1: leere Konfiguration -> Leerhinweis, kein <iframe>, kein Link', () => {
renderWidget({});
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('Test 2: http-Adresse -> ebenfalls Leerzustand (Resolver weist ab)', () => {
renderWidget({ url: 'http://a.de' });
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
});
it('Test 3: https-Adresse -> genau ein <iframe> mit src, title = Adresse, exakter Sandbox, no-referrer, lazy, allow leer, Nonce 0', () => {
const { container } = renderWidget({ url: URL_A });
expect(container.querySelectorAll('iframe')).toHaveLength(1);
const f = frame();
expect(f.getAttribute('src')).toBe(URL_A);
expect(f.getAttribute('title')).toBe(URL_A);
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(f.getAttribute('sandbox')).not.toMatch(/top-navigation/);
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(f.getAttribute('loading')).toBe('lazy');
expect(f.getAttribute('allow')).toBe('');
expect(f.getAttribute('data-reload-nonce')).toBe('0');
});
it('Test 4: ohne Titel -> kein <h2>, Link als Ecksymbol (absolute) mit href, _blank, noopener noreferrer', () => {
const { container } = renderWidget({ url: URL_A });
expect(container.querySelector('h2')).toBeNull();
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.getAttribute('href')).toBe(URL_A);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
expect(link.getAttribute('rel')).toContain('noreferrer');
expect(link.className).toContain('absolute');
});
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
const { container } = renderWidget({ url: URL_A, title: 'Board' });
expect(container.querySelector('h2')).toHaveTextContent('Board');
expect(frame().getAttribute('title')).toBe('Board');
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.closest('.border-b')).not.toBeNull();
expect(link.className).not.toContain('absolute');
});
it('Test 6: Titel <b>x</b> erscheint woertlich, kein <b>-Element (React-Escaping, T-QD3-09)', () => {
const { container } = renderWidget({ url: URL_A, title: '<b>x</b>' });
expect(screen.getByText('<b>x</b>')).toBeInTheDocument();
expect(container.querySelector('b')).toBeNull();
});
it('Test 7: reloadSeconds 60 -> ein Timer; nach 60 s neues <iframe>-Objekt mit Nonce 1, nach 120 s Nonce 2; unmount raeumt', () => {
const { unmount } = renderWidget({ url: URL_A, reloadSeconds: 60 });
expect(vi.getTimerCount()).toBe(1);
const before = frame();
act(() => {
vi.advanceTimersByTime(60_000);
});
const after = frame();
expect(after).not.toBe(before);
expect(after.getAttribute('data-reload-nonce')).toBe('1');
expect(after.getAttribute('src')).toBe(URL_A);
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(frame().getAttribute('data-reload-nonce')).toBe('2');
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it('Test 8: reloadSeconds 0 -> kein Timer, Nonce bleibt 0', () => {
renderWidget({ url: URL_A, reloadSeconds: 0 });
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(3_600_000);
});
expect(frame().getAttribute('data-reload-nonce')).toBe('0');
});
it('Test 9: Bearbeitungsmodus + reloadSeconds 60 -> kein Timer (kein Neuladen beim Bearbeiten)', () => {
renderWidget({ url: URL_A, reloadSeconds: 60 }, true);
expect(vi.getTimerCount()).toBe(0);
});
it('Test 10: Bearbeitungsmodus -> Overlay aria-hidden, absolute inset-0, im DOM NACH dem <iframe>', () => {
renderWidget({ url: URL_A }, true);
const overlay = screen.getByTestId('xframe-edit-overlay');
expect(overlay.getAttribute('aria-hidden')).toBe('true');
expect(overlay.className).toContain('absolute');
expect(overlay.className).toContain('inset-0');
// FOLLOWING = 4: das Overlay folgt dem Rahmen in Dokumentreihenfolge
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('Test 11: Ansichtsmodus -> kein Overlay', () => {
renderWidget({ url: URL_A }, false);
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
});
it('Test 12: Ecksymbol sitzt im Bearbeitungsmodus tiefer (top-6) als im Ansichtsmodus (top-1)', () => {
renderWidget({ url: URL_A }, false);
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-1');
cleanup();
renderWidget({ url: URL_A }, true);
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
});
});