import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; /** * Honest save state: dirty tracking, the status pill, the beforeunload * warning, the persistent error toast and "a failed save blocks publish". * Drives the real LoadGate + DirtyTracker + TopBar + SaveGuardProvider * against a mocked `fetch`; only `@craftjs/core`'s useEditor and sibling * chrome are stubbed (canvas edits are simulated with the same markDirty * App.tsx hands to Craft's onNodesChange). */ 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(), clear: vi.fn() }, deserialize: vi.fn() }; const collected = collector ? collector({}, query) : {}; return { actions, query, ...collected }; }, })); vi.mock('../panels/topbar/TemplateModal', () => ({ TemplateModal: () => null })); vi.mock('../panels/topbar/HeadCodeModal', () => ({ HeadCodeModal: () => null })); vi.mock('../panels/sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null })); import { LoadGate } from './LoadGate'; import { DirtyTracker } from './DirtyTracker'; import { TopBar } from '../panels/topbar/TopBar'; import { EditorConfigProvider } from '../state/EditorConfigContext'; import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext'; import { PageProvider } from '../state/PageContext'; import { SiteDesignProvider, useSiteDesign } from '../state/SiteDesignContext'; import { WhpConfig } from '../types'; const whpConfig: WhpConfig = { user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false, }; const jsonResp = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body, }); const htmlResp = (status: number) => ({ ok: status >= 200 && status < 300, status, json: async () => { throw new SyntaxError('Unexpected token <'); }, }); let container: HTMLDivElement; let root: Root; let fetchMock: ReturnType; let guard: ReturnType; let design: ReturnType; const Probe: React.FC = () => { guard = useSaveGuard(); design = useSiteDesign(); return null; }; const q = (sel: string) => container.querySelector(sel) as HTMLElement | null; const pill = () => q('[data-testid="save-status-pill"]')!.getAttribute('data-status'); const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`)); const edit = () => act(() => { guard.markDirty(); }); const clickSave = async () => { await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); await flush(); }; const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); }; function respondWith(handlers: { save?: unknown[]; publish?: unknown[] }) { const save = [...(handlers.save ?? [])]; const publish = [...(handlers.publish ?? [])]; fetchMock.mockImplementation(async (url: string) => { const u = String(url); if (u.includes('action=load')) return jsonResp({ success: true, project: null, rev: null }); if (u.includes('action=save')) return save.shift() ?? jsonResp({ success: true }); if (u.includes('action=publish')) return publish.shift() ?? jsonResp({ success: true }); throw new Error(`unexpected fetch ${u}`); }); } async function mount() { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render( {}} /> , ); }); await flush(); } let addSpy: ReturnType; let removeSpy: ReturnType; const beforeUnloadAdds = () => addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length; const beforeUnloadRemoves = () => removeSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length; beforeEach(() => { vi.useFakeTimers(); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); vi.spyOn(console, 'error').mockImplementation(() => {}); addSpy = vi.spyOn(window, 'addEventListener'); removeSpy = vi.spyOn(window, 'removeEventListener'); }); afterEach(() => { act(() => { root.unmount(); }); container.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('dirty lifecycle and status pill', () => { test('loaded site is clean; an edit makes it dirty; only a successful save clears it', async () => { respondWith({ save: [jsonResp({ success: false, error: 'x', message: 'nope' }, 500), jsonResp({ success: true })] }); await mount(); expect(guard.dirty).toBe(false); // rev: null -- the server has no copy yet, so not "Saved". expect(pill()).toBe('new'); edit(); expect(guard.dirty).toBe(true); expect(pill()).toBe('unsaved'); await clickSave(); expect(guard.dirty).toBe(true); expect(pill()).toBe('failed'); await clickSave(); expect(guard.dirty).toBe(false); expect(pill()).toBe('saved'); }); test('page/design state changes after load mark dirty; the load itself does not', async () => { respondWith({}); await mount(); expect(guard.dirty).toBe(false); act(() => { design.updateDesign({ headCode: '' }); }); expect(guard.dirty).toBe(true); }); test('canvas edits before the load finishes are ignored', async () => { fetchMock.mockImplementation(() => new Promise(() => {})); await mount(); edit(); expect(guard.dirty).toBe(false); }); test('an edit made while a save is in flight keeps the editor dirty', async () => { let finishSave!: (r: unknown) => void; fetchMock.mockImplementation((url: string) => String(url).includes('action=load') ? Promise.resolve(jsonResp({ success: true, project: null, rev: null })) : new Promise((res) => { finishSave = res; })); await mount(); edit(); await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); await flush(); expect(pill()).toBe('saving'); edit(); await act(async () => { finishSave(jsonResp({ success: true })); }); await flush(); expect(guard.dirty).toBe(true); expect(pill()).toBe('unsaved'); }); test('Published persists with no timer, and the next edit turns it into Unsaved changes', async () => { respondWith({}); await mount(); edit(); await clickPublish(); expect(pill()).toBe('published'); await act(async () => { vi.advanceTimersByTime(10_000); }); expect(pill()).toBe('published'); edit(); expect(pill()).toBe('unsaved'); }); test('Saved persists with no timer', async () => { respondWith({}); await mount(); edit(); await clickSave(); await act(async () => { vi.advanceTimersByTime(10_000); }); expect(pill()).toBe('saved'); }); }); describe('autosave only when dirty', () => { test('an idle 30s tick makes no request; an edit makes exactly one on the next tick', async () => { respondWith({}); await mount(); await act(async () => { vi.advanceTimersByTime(90_000); }); await flush(); expect(calls('save')).toHaveLength(0); edit(); await act(async () => { vi.advanceTimersByTime(30_000); }); await flush(); expect(calls('save')).toHaveLength(1); // Saved and clean again: later ticks stay quiet. await act(async () => { vi.advanceTimersByTime(60_000); }); await flush(); expect(calls('save')).toHaveLength(1); }); test('the Save button still saves a clean editor', async () => { respondWith({}); await mount(); await clickSave(); expect(calls('save')).toHaveLength(1); }); }); describe('Draft badge', () => { test('a manual save with nothing changed does not flip the badge to Draft; a save after real changes does', async () => { respondWith({}); await mount(); await clickSave(); expect(calls('save')).toHaveLength(1); expect(q('.publish-badge.draft')).toBeNull(); edit(); await act(async () => { vi.advanceTimersByTime(30_000); }); await flush(); expect(q('.publish-badge.draft')).not.toBeNull(); }); }); describe('beforeunload', () => { test('is registered only while dirty (or saving) and removed once saved', async () => { respondWith({}); await mount(); expect(beforeUnloadAdds()).toBe(0); edit(); expect(beforeUnloadAdds()).toBe(1); const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener; const ev = new Event('beforeunload', { cancelable: true }); handler(ev); expect(ev.defaultPrevented).toBe(true); await clickSave(); expect(beforeUnloadRemoves()).toBe(1); }); test('suppressBeforeUnload lets a deliberate reload through without a prompt', async () => { respondWith({}); await mount(); edit(); const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener; act(() => { guard.suppressBeforeUnload(); }); const ev = new Event('beforeunload', { cancelable: true }); handler(ev); expect(ev.defaultPrevented).toBe(false); }); }); describe('failed save blocks publish', () => { test('no publish request is made and the save error is shown', async () => { respondWith({ save: [jsonResp({ success: false, error: 'disk_full', message: 'Disk quota exceeded.' }, 500)] }); await mount(); edit(); await clickPublish(); expect(calls('publish')).toHaveLength(0); expect(q('[data-testid="error-notice"]')!.textContent).toContain('Disk quota exceeded.'); expect(pill()).toBe('failed'); }); test('a thrown save (network down) also blocks publish', async () => { fetchMock.mockImplementation(async (url: string) => { if (String(url).includes('action=load')) return jsonResp({ success: true, project: null, rev: null }); throw new TypeError('Failed to fetch'); }); await mount(); await clickPublish(); expect(calls('publish')).toHaveLength(0); expect(q('[data-testid="error-notice"]')!.textContent).toContain('Failed to fetch'); }); }); describe('error notice', () => { test('persists past any timer until dismissed, and shows the server message', async () => { // Every save fails, including the 30s autosaves that run during the wait. fetchMock.mockImplementation(async (url: string) => String(url).includes('action=load') ? jsonResp({ success: true, project: null, rev: null }) : jsonResp({ success: false, error: 'bad', message: 'Quota exceeded.' }, 500)); await mount(); edit(); await clickSave(); expect(q('[data-testid="error-notice"]')!.textContent).toContain('Quota exceeded.'); await act(async () => { vi.advanceTimersByTime(120_000); }); expect(q('[data-testid="error-notice"]')).not.toBeNull(); act(() => { q('[data-testid="error-notice-dismiss"]')!.click(); }); expect(q('[data-testid="error-notice"]')).toBeNull(); }); test('a non-JSON 502 yields a readable error, not a parse exception', async () => { respondWith({ save: [htmlResp(502)] }); await mount(); edit(); await clickSave(); expect(q('[data-testid="error-notice"]')!.textContent).toBe('Save failed: server returned HTTP 502'); expect(pill()).toBe('failed'); }); test('a non-JSON 502 on publish is readable too', async () => { respondWith({ publish: [htmlResp(502)] }); await mount(); await clickPublish(); expect(q('[data-testid="error-notice"]')!.textContent).toBe('Publish failed: server returned HTTP 502'); expect(pill()).not.toBe('published'); }); test('a later successful save clears the notice', async () => { respondWith({ save: [htmlResp(502), jsonResp({ success: true })] }); await mount(); edit(); await clickSave(); await clickSave(); expect(q('[data-testid="error-notice"]')).toBeNull(); }); }); describe('server refusal still goes to the dedicated banner', () => { test('empty_overwrite_refused shows the banner, not the error notice', async () => { respondWith({ save: [jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)] }); await mount(); edit(); await clickSave(); expect(q('[data-testid="empty-save-banner"]')).not.toBeNull(); expect(q('[data-testid="error-notice"]')).toBeNull(); expect(pill()).toBe('unsaved'); }); }); describe('publish racing an edit', () => { test('an edit during an in-flight publish leaves the site a draft, not Published', async () => { let finishPublish!: (r: unknown) => void; fetchMock.mockImplementation((url: string) => { const u = String(url); if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null })); if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; }); return Promise.resolve(jsonResp({ success: true })); }); await mount(); edit(); await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); expect(calls('publish')).toHaveLength(1); edit(); // typed while the publish request is out await act(async () => { finishPublish(jsonResp({ success: true })); }); await flush(); expect(pill()).toBe('unsaved'); await clickSave(); expect(pill()).toBe('saved'); expect(q('.publish-badge.draft')).not.toBeNull(); }); test('autosave does not start while a publish is running', async () => { let finishPublish!: (r: unknown) => void; fetchMock.mockImplementation((url: string) => { const u = String(url); if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null })); if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; }); return Promise.resolve(jsonResp({ success: true })); }); await mount(); edit(); await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); const savesBefore = calls('save').length; // the publish's own save await act(async () => { vi.advanceTimersByTime(30_000); }); await flush(); expect(calls('save')).toHaveLength(savesBefore); await act(async () => { finishPublish(jsonResp({ success: true })); }); await flush(); }); }); describe('populated project load', () => { test('pages, header/footer and design restored by load() leave the editor clean', async () => { const state = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} }, }); fetchMock.mockImplementation(async () => jsonResp({ success: true, rev: 'r1', project: { design: { headCode: '' }, header_craft_state: state, footer_craft_state: state, pages_craft_state: [ { id: 'home', name: 'Home', slug: 'index', craftState: state }, { id: 'page_2', name: 'About', slug: 'about', craftState: state }, ], }, })); await mount(); await flush(); expect(guard.loadState).toBe('loaded'); expect(pill()).toBe('saved'); expect(guard.dirty).toBe(false); expect(addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload')).toHaveLength(0); // ...and a real edit afterwards still registers. edit(); expect(pill()).toBe('unsaved'); }); });