diff --git a/craft/src/hooks/useWhpApi.export-cache.test.ts b/craft/src/hooks/useWhpApi.export-cache.test.ts index 4c2db7d..7c8d645 100644 --- a/craft/src/hooks/useWhpApi.export-cache.test.ts +++ b/craft/src/hooks/useWhpApi.export-cache.test.ts @@ -2,7 +2,8 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; vi.mock('../utils/html-export', () => ({ exportBodyHtml: vi.fn((state: string) => { - if (state.startsWith('BAD')) throw new Error('export boom'); + // Like the real exportBodyHtml: failures are swallowed into an empty result. + if (state.startsWith('BAD')) return { html: '', css: '' }; return { html: `

${state}

`, css: `/*${state}*/` }; }), })); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 873c65d..17b7041 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -80,7 +80,13 @@ export function buildSavePayload(input: BuildSavePayloadInput) { const hit = exportCache.get(state); if (hit) return hit; const result = exportBodyHtml(state); - exportCache.set(state, result); + // exportBodyHtml swallows its own errors and returns empty html; a + // non-empty state yielding '' is a failed export, so retry it next build. + if (result.html === '' && state !== '') { + console.error('HTML export produced no output; not caching, will retry next save.'); + } else { + exportCache.set(state, result); + } return result; }; diff --git a/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx new file mode 100644 index 0000000..b2fc167 --- /dev/null +++ b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx @@ -0,0 +1,105 @@ +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'; + +/** + * Autosave must not depend on isDirty() alone: with a context that reports + * dirty but a load that failed, the load gate has to hold on its own. + * (A real provider can't produce that state, since markDirty is ignored + * until loaded, so the guard is stubbed.) + */ +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() }, deserialize: vi.fn() }; + return { actions, query, ...(collector ? collector({}, query) : {}) }; + }, +})); +vi.mock('./TemplateModal', () => ({ TemplateModal: () => null })); +vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null })); +vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null })); + +// save() is stubbed (the real one also refuses before load) so this test +// exercises TopBar's own gate, not the API hook's. +const saveMock = vi.hoisted(() => vi.fn()); +vi.mock('../../hooks/useWhpApi', () => ({ + useWhpApi: () => ({ + save: saveMock, publish: vi.fn(), load: vi.fn(), uploadAsset: vi.fn(), isWHP: true, + }), +})); + +const guardState = vi.hoisted(() => ({ loadState: 'failed' as 'failed' | 'loaded' })); +vi.mock('../../state/SaveGuardContext', () => ({ + useSaveGuard: () => ({ + loadState: guardState.loadState, + setLoadState: () => {}, + emptyRefusal: null, + setEmptyRefusal: () => {}, + forceEmptyNextSave: { current: false }, + armForceEmptyNextSave: () => {}, + dirty: true, + isDirty: () => true, + getVersion: () => 1, + markDirty: () => {}, + beginSave: () => ({ version: 1 }), + endSave: () => {}, + saving: false, + suppressBeforeUnload: () => {}, + }), +})); + +import { TopBar } from './TopBar'; +import { EditorConfigProvider } from '../../state/EditorConfigContext'; +import { WhpConfig } from '../../types'; + +const whpConfig: WhpConfig = { + user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, + siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false, +}; + +let container: HTMLDivElement; +let root: Root; +const saves = () => saveMock.mock.calls; + +function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + {}} showGuides onToggleGuides={() => {}} /> + , + ); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + saveMock.mockReset(); + saveMock.mockResolvedValue({ success: true }); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('autosave gate', () => { + test('a dirty editor whose load failed makes no save call', async () => { + guardState.loadState = 'failed'; + mount(); + await act(async () => { vi.advanceTimersByTime(90_000); }); + expect(saves()).toHaveLength(0); + }); + + test('control: the same dirty editor, once loaded, does autosave', async () => { + guardState.loadState = 'loaded'; + mount(); + await act(async () => { vi.advanceTimersByTime(30_000); }); + expect(saves()).toHaveLength(1); + }); +});