Files
site-builder/craft/src/editor/SaveState.test.tsx
T
shadowdaoandClaude Opus 5.5 5f99df23a7 feat(site-builder): per-breakpoint style model, device context and responsive export
- utils/breakpoints.ts: single source for 767/768/991/992; head
  VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from
  768 to the 767 mobile boundary).
- utils/responsive-style.ts: styleTablet/styleMobile partial overrides,
  cascade resolution, pure setters, scoped @media CSS per node.
- state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile
  forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and
  Canvas read useDevice() instead of props.
- editor/RenderNode.tsx: resolves the effective style for the current
  device in one place and marks overridden nodes with data-wsb-overridden.
  Craft passes an opaque <DefaultRender/> to onRender, so the component
  element is rebuilt with the resolved style (cloneElement cannot work).
- utils/html-export.ts: hidden nodes skipped; overrides emitted as one
  body-level <style data-wsb-responsive> block + data-wsb-r scope attr;
  injectAttrs skips leading <style> elements.
- test-utils/editorHarness.tsx: optional onRender/wrap options.

Refs #45
Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:28:44 -07:00

436 lines
16 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';
/**
* Honest save state: dirty tracking, the status pill, the beforeunload
* warning, the persistent error toast and "a failed save blocks publish".
* Drives the real LoadGate + DirtyTracker + TopBar + SaveGuardProvider
* against a mocked `fetch`; only `@craftjs/core`'s useEditor and sibling
* chrome are stubbed (canvas edits are simulated with the same markDirty
* App.tsx hands to Craft's onNodesChange).
*/
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() };
const collected = collector ? collector({}, query) : {};
return { actions, query, ...collected };
},
}));
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 { 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,
});
const htmlResp = (status: number) => ({
ok: status >= 200 && status < 300,
status,
json: async () => { throw new SyntaxError('Unexpected token <'); },
});
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
let guard: ReturnType<typeof useSaveGuard>;
let design: ReturnType<typeof useSiteDesign>;
const Probe: React.FC = () => {
guard = useSaveGuard();
design = useSiteDesign();
return null;
};
const q = (sel: string) => container.querySelector(sel) as HTMLElement | 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 edit = () => act(() => { guard.markDirty(); });
const clickSave = async () => { await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); await flush(); };
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); };
function respondWith(handlers: { save?: unknown[]; publish?: unknown[] }) {
const save = [...(handlers.save ?? [])];
const publish = [...(handlers.publish ?? [])];
fetchMock.mockImplementation(async (url: string) => {
const u = String(url);
if (u.includes('action=load')) return jsonResp({ success: true, project: null, rev: null });
if (u.includes('action=save')) return save.shift() ?? jsonResp({ success: true });
if (u.includes('action=publish')) return publish.shift() ?? jsonResp({ success: true });
throw new Error(`unexpected fetch ${u}`);
});
}
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();
}
let addSpy: ReturnType<typeof vi.spyOn>;
let removeSpy: ReturnType<typeof vi.spyOn>;
const beforeUnloadAdds = () => addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length;
const beforeUnloadRemoves = () => removeSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
addSpy = vi.spyOn(window, 'addEventListener');
removeSpy = vi.spyOn(window, 'removeEventListener');
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('dirty lifecycle and status pill', () => {
test('loaded site is clean; an edit makes it dirty; only a successful save clears it', async () => {
respondWith({ save: [jsonResp({ success: false, error: 'x', message: 'nope' }, 500), jsonResp({ success: true })] });
await mount();
expect(guard.dirty).toBe(false);
// rev: null -- the server has no copy yet, so not "Saved".
expect(pill()).toBe('new');
edit();
expect(guard.dirty).toBe(true);
expect(pill()).toBe('unsaved');
await clickSave();
expect(guard.dirty).toBe(true);
expect(pill()).toBe('failed');
await clickSave();
expect(guard.dirty).toBe(false);
expect(pill()).toBe('saved');
});
test('page/design state changes after load mark dirty; the load itself does not', async () => {
respondWith({});
await mount();
expect(guard.dirty).toBe(false);
act(() => { design.updateDesign({ headCode: '<!-- x -->' }); });
expect(guard.dirty).toBe(true);
});
test('canvas edits before the load finishes are ignored', async () => {
fetchMock.mockImplementation(() => new Promise(() => {}));
await mount();
edit();
expect(guard.dirty).toBe(false);
});
test('an edit made while a save is in flight keeps the editor dirty', async () => {
let finishSave!: (r: unknown) => void;
fetchMock.mockImplementation((url: string) =>
String(url).includes('action=load')
? Promise.resolve(jsonResp({ success: true, project: null, rev: null }))
: new Promise((res) => { finishSave = res; }));
await mount();
edit();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
expect(pill()).toBe('saving');
edit();
await act(async () => { finishSave(jsonResp({ success: true })); });
await flush();
expect(guard.dirty).toBe(true);
expect(pill()).toBe('unsaved');
});
test('Published persists with no timer, and the next edit turns it into Unsaved changes', async () => {
respondWith({});
await mount();
edit();
await clickPublish();
expect(pill()).toBe('published');
await act(async () => { vi.advanceTimersByTime(10_000); });
expect(pill()).toBe('published');
edit();
expect(pill()).toBe('unsaved');
});
test('Saved persists with no timer', async () => {
respondWith({});
await mount();
edit();
await clickSave();
await act(async () => { vi.advanceTimersByTime(10_000); });
expect(pill()).toBe('saved');
});
});
describe('autosave only when dirty', () => {
test('an idle 30s tick makes no request; an edit makes exactly one on the next tick', async () => {
respondWith({});
await mount();
await act(async () => { vi.advanceTimersByTime(90_000); });
await flush();
expect(calls('save')).toHaveLength(0);
edit();
await act(async () => { vi.advanceTimersByTime(30_000); });
await flush();
expect(calls('save')).toHaveLength(1);
// Saved and clean again: later ticks stay quiet.
await act(async () => { vi.advanceTimersByTime(60_000); });
await flush();
expect(calls('save')).toHaveLength(1);
});
test('the Save button still saves a clean editor', async () => {
respondWith({});
await mount();
await clickSave();
expect(calls('save')).toHaveLength(1);
});
});
describe('Draft badge', () => {
test('a manual save with nothing changed does not flip the badge to Draft; a save after real changes does', async () => {
respondWith({});
await mount();
await clickSave();
expect(calls('save')).toHaveLength(1);
expect(q('.publish-badge.draft')).toBeNull();
edit();
await act(async () => { vi.advanceTimersByTime(30_000); });
await flush();
expect(q('.publish-badge.draft')).not.toBeNull();
});
});
describe('beforeunload', () => {
test('is registered only while dirty (or saving) and removed once saved', async () => {
respondWith({});
await mount();
expect(beforeUnloadAdds()).toBe(0);
edit();
expect(beforeUnloadAdds()).toBe(1);
const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener;
const ev = new Event('beforeunload', { cancelable: true });
handler(ev);
expect(ev.defaultPrevented).toBe(true);
await clickSave();
expect(beforeUnloadRemoves()).toBe(1);
});
test('suppressBeforeUnload lets a deliberate reload through without a prompt', async () => {
respondWith({});
await mount();
edit();
const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener;
act(() => { guard.suppressBeforeUnload(); });
const ev = new Event('beforeunload', { cancelable: true });
handler(ev);
expect(ev.defaultPrevented).toBe(false);
});
});
describe('failed save blocks publish', () => {
test('no publish request is made and the save error is shown', async () => {
respondWith({ save: [jsonResp({ success: false, error: 'disk_full', message: 'Disk quota exceeded.' }, 500)] });
await mount();
edit();
await clickPublish();
expect(calls('publish')).toHaveLength(0);
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Disk quota exceeded.');
expect(pill()).toBe('failed');
});
test('a thrown save (network down) also blocks publish', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (String(url).includes('action=load')) return jsonResp({ success: true, project: null, rev: null });
throw new TypeError('Failed to fetch');
});
await mount();
await clickPublish();
expect(calls('publish')).toHaveLength(0);
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Failed to fetch');
});
});
describe('error notice', () => {
test('persists past any timer until dismissed, and shows the server message', async () => {
// Every save fails, including the 30s autosaves that run during the wait.
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: jsonResp({ success: false, error: 'bad', message: 'Quota exceeded.' }, 500));
await mount();
edit();
await clickSave();
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Quota exceeded.');
await act(async () => { vi.advanceTimersByTime(120_000); });
expect(q('[data-testid="error-notice"]')).not.toBeNull();
act(() => { q('[data-testid="error-notice-dismiss"]')!.click(); });
expect(q('[data-testid="error-notice"]')).toBeNull();
});
test('a non-JSON 502 yields a readable error, not a parse exception', async () => {
respondWith({ save: [htmlResp(502)] });
await mount();
edit();
await clickSave();
expect(q('[data-testid="error-notice"]')!.textContent).toBe('Save failed: server returned HTTP 502');
expect(pill()).toBe('failed');
});
test('a non-JSON 502 on publish is readable too', async () => {
respondWith({ publish: [htmlResp(502)] });
await mount();
await clickPublish();
expect(q('[data-testid="error-notice"]')!.textContent).toBe('Publish failed: server returned HTTP 502');
expect(pill()).not.toBe('published');
});
test('a later successful save clears the notice', async () => {
respondWith({ save: [htmlResp(502), jsonResp({ success: true })] });
await mount();
edit();
await clickSave();
await clickSave();
expect(q('[data-testid="error-notice"]')).toBeNull();
});
});
describe('server refusal still goes to the dedicated banner', () => {
test('empty_overwrite_refused shows the banner, not the error notice', async () => {
respondWith({ save: [jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)] });
await mount();
edit();
await clickSave();
expect(q('[data-testid="empty-save-banner"]')).not.toBeNull();
expect(q('[data-testid="error-notice"]')).toBeNull();
expect(pill()).toBe('unsaved');
});
});
describe('publish racing an edit', () => {
test('an edit during an in-flight publish leaves the site a draft, not Published', async () => {
let finishPublish!: (r: unknown) => void;
fetchMock.mockImplementation((url: string) => {
const u = String(url);
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null }));
if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; });
return Promise.resolve(jsonResp({ success: true }));
});
await mount();
edit();
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
await flush();
expect(calls('publish')).toHaveLength(1);
edit(); // typed while the publish request is out
await act(async () => { finishPublish(jsonResp({ success: true })); });
await flush();
expect(pill()).toBe('unsaved');
await clickSave();
expect(pill()).toBe('saved');
expect(q('.publish-badge.draft')).not.toBeNull();
});
test('autosave does not start while a publish is running', async () => {
let finishPublish!: (r: unknown) => void;
fetchMock.mockImplementation((url: string) => {
const u = String(url);
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null }));
if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; });
return Promise.resolve(jsonResp({ success: true }));
});
await mount();
edit();
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
await flush();
const savesBefore = calls('save').length; // the publish's own save
await act(async () => { vi.advanceTimersByTime(30_000); });
await flush();
expect(calls('save')).toHaveLength(savesBefore);
await act(async () => { finishPublish(jsonResp({ success: true })); });
await flush();
});
});
describe('populated project load', () => {
test('pages, header/footer and design restored by load() leave the editor clean', async () => {
const state = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
fetchMock.mockImplementation(async () => jsonResp({
success: true,
rev: 'r1',
project: {
design: { headCode: '<!-- loaded -->' },
header_craft_state: state,
footer_craft_state: state,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: state },
{ id: 'page_2', name: 'About', slug: 'about', craftState: state },
],
},
}));
await mount();
await flush();
expect(guard.loadState).toBe('loaded');
expect(pill()).toBe('saved');
expect(guard.dirty).toBe(false);
expect(addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload')).toHaveLength(0);
// ...and a real edit afterwards still registers.
edit();
expect(pill()).toBe('unsaved');
});
});