fix(site-builder): don't cache swallowed export failures; load-bearing autosave gate test

Refs #33

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 10:15:24 -07:00
1 parent c863767283
commit a20c9bae28
3 files changed
+114 -2

No files matched your search

@@ -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: `<p>${state}</p>`, css: `/*${state}*/` };
}),
}));
+7 -1
View File
@@ -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;
};
@@ -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<string, unknown>) => {
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(
<EditorConfigProvider config={whpConfig}>
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
</EditorConfigProvider>,
);
});
}
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);
});
});