diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx new file mode 100644 index 0000000..9a94b19 --- /dev/null +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -0,0 +1,191 @@ +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(); + }); +});