Files
site-builder/craft/src/editor/SaveSerialise.test.tsx
T
shadowdao c0fd183c11 feat(site-builder): pre-publish checklist and canonical link
Publish now runs client-side checks (image alt, meta description, title
length, H1, empty links, contrast, noindex landing, empty page) over the
current state and shows a grouped checklist with Fix links and
"Publish anyway". Adds ExportOptions.canonicalUrl -> <link rel="canonical">
after the favicon line in both Preview and export.

Refs #54
2026-10-07 12:05:28 -07:00

245 lines
9.0 KiB
TypeScript

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 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 confirmChecklist(); 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);
});
});
/** #54: the pre-publish checklist opens for these fixtures (an empty Home with no
* meta description); these tests are about the publish path, so confirm it. */
async function confirmChecklist() {
const b = q('[data-testid="publish-checklist-confirm"]') as HTMLElement | null;
if (b) await act(async () => { b.click(); });
}