fix(site-builder): serialise all saves; honest pill for conflicts and new sites
A manual Save during Publish sent a second save with the same base_rev; the loser 409'd against its own tab (and could abort the publish). - useWhpApi: every save runs through one in-flight promise (SaveGuard saveInFlight). A save requested while one is out waits, then sends only if still dirty, with the rev the first returned; payload is read from the editor at send time. Forced (empty/overwrite) saves always send. - Save buttons are disabled while publishing. - Pill: a conflict reads 'Unsaved changes' (modal open) from Save and from Publish alike; a never-saved site (load rev null) reads 'Not saved yet'. - Autosave interval reads runSave through a ref, so editing can't keep restarting it and starve autosave. - Fix stale 'Throws like exportBodyHtml' comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
572e736f1f
commit
97ccbee461
7 files changed
+335
-20
No files matched your search
@@ -0,0 +1,237 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* One save at a time. Every save (Save button, autosave, the save inside
|
||||
* Publish) goes through a single in-flight promise in useWhpApi, so a second
|
||||
* save can never race the first with the same base_rev and 409 against its
|
||||
* own tab. Also: the pill for conflicts and brand-new sites, and an autosave
|
||||
* timer that continuous editing cannot starve.
|
||||
*
|
||||
* The fake server checks base_rev when it PROCESSES a save (in arrival
|
||||
* order), like the real one: a save based on a stale rev gets a 409.
|
||||
*/
|
||||
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(), clear: vi.fn() }, deserialize: vi.fn() };
|
||||
return { actions, query, ...(collector ? collector({}, query) : {}) };
|
||||
},
|
||||
}));
|
||||
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 { useWhpApi } from '../hooks/useWhpApi';
|
||||
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 });
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
let design: ReturnType<typeof useSiteDesign>;
|
||||
let api: ReturnType<typeof useWhpApi>;
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
guard = useSaveGuard();
|
||||
design = useSiteDesign();
|
||||
api = useWhpApi();
|
||||
return null;
|
||||
};
|
||||
|
||||
const q = (sel: string) => container.querySelector(sel) as HTMLButtonElement | 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 saveBody = (i: number) => JSON.parse(calls('save')[i][1].body);
|
||||
|
||||
/** Fake server. Saves wait in `pending` until served, then are checked against the server's rev. */
|
||||
let serverRev: string | null;
|
||||
let revN: number;
|
||||
let pending: { body: any; resolve: (r: unknown) => void }[];
|
||||
let statuses: number[];
|
||||
let holdSaves: boolean;
|
||||
function serveSave(p: { body: any; resolve: (r: unknown) => void }) {
|
||||
if (p.body.base_rev !== serverRev && !p.body.force_overwrite) {
|
||||
statuses.push(409);
|
||||
p.resolve(jsonResp({ success: false, error: 'conflict', message: 'other tab' }, 409));
|
||||
return;
|
||||
}
|
||||
revN += 1;
|
||||
serverRev = `r${revN}`;
|
||||
statuses.push(200);
|
||||
p.resolve(jsonResp({ success: true, rev: serverRev }));
|
||||
}
|
||||
async function serveNext() {
|
||||
await act(async () => { serveSave(pending.shift()!); });
|
||||
await flush();
|
||||
}
|
||||
|
||||
function server(loadRev: string | null) {
|
||||
serverRev = loadRev;
|
||||
revN = 1;
|
||||
pending = [];
|
||||
statuses = [];
|
||||
fetchMock.mockImplementation((url: string, init?: { body?: string }) => {
|
||||
const u = String(url);
|
||||
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: loadRev }));
|
||||
if (u.includes('action=publish')) return Promise.resolve(jsonResp({ success: true }));
|
||||
const body = JSON.parse(init!.body!);
|
||||
return new Promise((resolve) => {
|
||||
const p = { body, resolve };
|
||||
if (holdSaves) pending.push(p); else serveSave(p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<Probe />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn();
|
||||
holdSaves = false;
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const edit = () => act(() => { guard.markDirty(); });
|
||||
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); };
|
||||
|
||||
describe('saves are serialised', () => {
|
||||
test('a save requested during an in-flight publish waits, then saves with the rev the first save returned', async () => {
|
||||
server('r1');
|
||||
await mount();
|
||||
holdSaves = true;
|
||||
edit();
|
||||
await clickPublish();
|
||||
expect(calls('save')).toHaveLength(1); // the publish's own save, still in flight
|
||||
edit(); // more typing while it is out...
|
||||
let second!: Promise<unknown>;
|
||||
await act(async () => { second = api.save(); }); // ...and a save request (any path)
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1); // queued, not sent alongside
|
||||
|
||||
await serveNext(); // first save lands: r2
|
||||
expect(calls('save')).toHaveLength(2);
|
||||
expect(saveBody(0).base_rev).toBe('r1');
|
||||
expect(saveBody(1).base_rev).toBe('r2');
|
||||
await serveNext();
|
||||
await act(async () => { await second; });
|
||||
expect(statuses).toEqual([200, 200]);
|
||||
expect(q('[data-testid="conflict-modal"]')).toBeNull();
|
||||
expect(calls('publish')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a queued save is skipped when the save it waited for left nothing unsaved', async () => {
|
||||
server('r1');
|
||||
await mount();
|
||||
holdSaves = true;
|
||||
edit();
|
||||
await clickPublish();
|
||||
let second!: Promise<any>;
|
||||
await act(async () => { second = api.save(); });
|
||||
await serveNext();
|
||||
while (pending.length) await serveNext(); // never hang: serve anything else that went out
|
||||
let result: any;
|
||||
await act(async () => { result = await second; });
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('the Save button is disabled while a publish is running', async () => {
|
||||
server('r1');
|
||||
await mount();
|
||||
holdSaves = true;
|
||||
edit();
|
||||
await clickPublish();
|
||||
expect(q('button[aria-label="Save Draft"]')!.disabled).toBe(true);
|
||||
await serveNext();
|
||||
expect(q('button[aria-label="Save Draft"]')!.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status pill', () => {
|
||||
test('a conflict shows "Unsaved changes" whether it came from Save or from Publish', async () => {
|
||||
server('r1');
|
||||
await mount();
|
||||
serverRev = 'r-other'; // another tab saved
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
expect(q('[data-testid="conflict-modal"]')).not.toBeNull();
|
||||
const fromSave = pill();
|
||||
|
||||
act(() => { guard.setConflict(null); });
|
||||
await clickPublish();
|
||||
expect(q('[data-testid="conflict-modal"]')).not.toBeNull();
|
||||
expect(fromSave).toBe('unsaved');
|
||||
expect(pill()).toBe('unsaved');
|
||||
});
|
||||
|
||||
test('a brand-new site that was never saved says "Not saved yet", not "Saved"', async () => {
|
||||
server(null);
|
||||
await mount();
|
||||
expect(pill()).toBe('new');
|
||||
expect(q('[data-testid="save-status-pill"]')!.textContent).toContain('Not saved yet');
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
expect(pill()).toBe('saved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave timer', () => {
|
||||
test('continuous design edits do not keep restarting the 30s autosave interval', async () => {
|
||||
server('r1');
|
||||
await mount();
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
act(() => { design.updateDesign({ headCode: `<!-- ${i} -->` }); });
|
||||
await act(async () => { vi.advanceTimersByTime(10_000); });
|
||||
}
|
||||
await flush();
|
||||
expect(calls('save').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -133,7 +133,8 @@ describe('dirty lifecycle and status pill', () => {
|
||||
respondWith({ save: [jsonResp({ success: false, error: 'x', message: 'nope' }, 500), jsonResp({ success: true })] });
|
||||
await mount();
|
||||
expect(guard.dirty).toBe(false);
|
||||
expect(pill()).toBe('saved');
|
||||
// rev: null -- the server has no copy yet, so not "Saved".
|
||||
expect(pill()).toBe('new');
|
||||
|
||||
edit();
|
||||
expect(guard.dirty).toBe(true);
|
||||
|
||||
Reference in new issue
Block a user