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:
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}*/` };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user