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
245 lines
9.0 KiB
TypeScript
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(); });
|
|
}
|