import { describe, test, expect, vi, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; /** * Closes the gap flagged by the whole-branch review: PublishWarnings.test.tsx * covers the presentational banner in isolation, but nothing asserted that * `result.warnings` from `publish()` actually reaches it through TopBar. That * 3-line seam (handlePublish -> setPublishWarnings -> ) is * exactly what regressed before this feature existed: the backend has always * returned `warnings`, and TopBar discarded them by only checking * `result.success` -- so the contact-form-relay warning was dead code for its * entire life. This suite drives the real `` through a real button * click and asserts the warnings show up, survive the 3s "Published" flash, * don't taint the success/error status, and get cleared by a fresh publish. * * Mocks (same DOM-harness + `vi.mock('@craftjs/core', ...)` pattern as * RenderNode.test.tsx / useWhpApi.load.test.tsx -- no @testing-library/react * in this repo): * - `@craftjs/core`'s `useEditor`: TopBar only needs inert undo/redo/query * stubs, not a real Craft.js tree. * - `useWhpApi`: this IS the seam under test -- `publish` is a controllable * mock so each test can choose exactly what the "backend" returns. * - TemplateModal / HeadCodeModal / SitesmithButton: sibling chrome * unrelated to the warnings seam (portals, CodeMirror lazy-load, * useSitesmith's own fetch calls) -- stubbed out so this suite stays * focused, same as MobilePanelBar.test.tsx stubbing its sibling panels. * `usePages`/`useSiteDesign`/`useMobileChrome` are left un-mocked and * un-provided -- their default context values (defined in each context * module) are harmless no-op stubs, and TopBar's desktop render path never * needs more than that. */ vi.mock('@craftjs/core', () => ({ useEditor: (collector?: (state: unknown, query: unknown) => Record) => { const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false }, }; const actions = { history: { undo: vi.fn(), redo: vi.fn() } }; const collected = collector ? collector({}, query) : {}; return { actions, query, ...collected }; }, })); const publishMock = vi.fn(); vi.mock('../../hooks/useWhpApi', () => ({ useWhpApi: () => ({ save: vi.fn().mockResolvedValue({ success: true }), publish: publishMock, load: vi.fn().mockResolvedValue(null), uploadAsset: vi.fn(), isWHP: true, }), })); vi.mock('./TemplateModal', () => ({ TemplateModal: () => null })); vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null })); vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null })); import { TopBar } from './TopBar'; import { EditorConfigProvider } from '../../state/EditorConfigContext'; import { WhpConfig } from '../../types'; const whpConfig: WhpConfig = { user: 'testuser', apiUrl: '/panel/api/site-builder', csrfToken: 'tok', siteId: 1, siteDomain: 'example.com', siteName: 'Example Site', backUrl: '/panel/sites', isRoot: false, }; let container: HTMLDivElement; let root: Root; function render() { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render( {}} showGuides={false} onToggleGuides={() => {}} /> , ); }); } function publishButton(): HTMLButtonElement { const btn = container.querySelector('.topbar-btn.publish'); if (!btn) throw new Error('Publish button not found'); return btn; } /** handlePublish does exactly one `await publish()` before touching state; * two microtask flushes inside the same act() batch is enough to carry that * through to the resulting re-render. */ async function clickPublish() { await act(async () => { publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); } afterEach(() => { if (container) { act(() => { root.unmount(); }); container.remove(); } publishMock.mockReset(); vi.useRealTimers(); }); describe('TopBar publish-warnings wiring', () => { test('warnings from publish() flow through into the rendered banner', async () => { publishMock.mockResolvedValue({ success: true, warnings: ['Warning one.', 'Warning two.'] }); render(); await clickPublish(); expect(container.textContent).toContain('Warning one.'); expect(container.textContent).toContain('Warning two.'); }); test('a clean publish (no warnings key) renders no banner at all', async () => { publishMock.mockResolvedValue({ success: true }); render(); await clickPublish(); expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull(); }); test('a warning is non-blocking -- publish still reports success, not a failure', async () => { publishMock.mockResolvedValue({ success: true, warnings: ['Submissions will not be delivered until an administrator enables it.'], }); render(); await clickPublish(); expect(container.querySelector('.publish-badge.published')).not.toBeNull(); expect(container.querySelector('.save-indicator.error')).toBeNull(); expect(container.textContent).toContain('Submissions will not be delivered until an administrator enables it.'); }); test('warnings survive the 3-second "Published" flash timer', async () => { vi.useFakeTimers(); publishMock.mockResolvedValue({ success: true, warnings: ['Sticks around after the flash.'] }); render(); await act(async () => { publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); // Sanity: both the flash and the warning are up before the timer fires. expect(container.querySelector('.publish-badge.published')).not.toBeNull(); expect(container.textContent).toContain('Sticks around after the flash.'); act(() => { vi.advanceTimersByTime(3000); }); // The 3s timer resets publishStatus -> the "Published" flash is gone... expect(container.querySelector('.publish-badge.published')).toBeNull(); // ...but publishWarnings lives in its own state and must NOT have been // cleared by that same timer. expect(container.textContent).toContain('Sticks around after the flash.'); }); test('a new publish attempt clears warnings left over from the previous one', async () => { publishMock.mockResolvedValueOnce({ success: true, warnings: ['Old warning.'] }); render(); await clickPublish(); expect(container.textContent).toContain('Old warning.'); publishMock.mockResolvedValueOnce({ success: true }); await clickPublish(); expect(container.textContent).not.toContain('Old warning.'); expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull(); }); });