Files
site-builder/craft/src/editor/LoadGate.save-guard.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

262 lines
9.9 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';
/**
* Data-safety: a failed initial load must never let autosave overwrite the
* saved site with a blank canvas. Drives the real LoadGate + TopBar +
* SiteDesignPanel against a mocked `fetch` (the backend contract) and fake
* timers; only `@craftjs/core`'s useEditor and sibling chrome are stubbed.
*/
const undoMock = vi.fn();
// The banner's Undo is disabled when Craft reports nothing to undo; tests that click it set this.
const mockCanUndo = { value: false };
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = {
serialize: () => '{}',
history: { canUndo: () => mockCanUndo.value, canRedo: () => false },
};
const actions = { history: { undo: undoMock, redo: vi.fn(), clear: vi.fn() }, deserialize: deserializeMock };
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 { TopBar } from '../panels/topbar/TopBar';
import { SiteDesignPanel } from '../panels/right/SiteDesignPanel';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } 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,
});
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
const saveCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).includes('action=save'));
const saveBody = (i: number) => JSON.parse(saveCalls()[i][1].body);
const q = (sel: string) => container.querySelector(sel) as HTMLElement | null;
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
let guard: ReturnType<typeof useSaveGuard>;
const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; };
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar showGuides onToggleGuides={() => {}} />
<SiteDesignPanel />
<GuardProbe />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
</EditorConfigProvider>,
);
});
}
beforeEach(() => {
mockCanUndo.value = false;
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
undoMock.mockClear();
deserializeMock.mockReset();
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('load gate', () => {
test('a failed load blocks autosave and manual save, and shows the failure message', async () => {
fetchMock.mockRejectedValue(new Error('network down'));
mount();
await flush();
expect(q('[data-testid="load-gate"]')!.textContent).toContain(
"We couldn't load your site. Your saved work has not been changed.",
);
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveCalls()).toHaveLength(0);
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(true);
expect((q('button[aria-label="Publish to live site"]') as HTMLButtonElement).disabled).toBe(true);
});
test('non-ok, non-JSON and success:false responses all count as failed', async () => {
for (const bad of [
jsonResp({ success: true, project: null }, 500),
{ ok: true, status: 200, json: async () => { throw new SyntaxError('bad json'); } },
jsonResp({ success: false, message: 'nope' }),
]) {
fetchMock.mockReset();
fetchMock.mockResolvedValue(bad);
mount();
await flush();
expect(q('[data-testid="load-retry"]')).not.toBeNull();
act(() => { root.unmount(); });
container.remove();
}
mount(); // so afterEach has something to unmount
});
test('a page state that fails to deserialize fails the load, names the problem, and never saves', async () => {
fetchMock.mockResolvedValue(jsonResp({
success: true,
rev: 'r1',
project: {
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }],
},
}));
deserializeMock.mockImplementationOnce(() => { throw new Error('malformed'); });
mount();
await flush();
expect(q('[data-testid="load-gate"]')!.textContent).toContain("We couldn't read part of your saved site.");
expect(q('[data-testid="load-retry"]')).not.toBeNull();
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveCalls()).toHaveLength(0);
});
test('canvas-changing top-bar controls are disabled until the site has loaded', async () => {
fetchMock.mockRejectedValue(new Error('down'));
mount();
await flush();
for (const label of ['Undo', 'Redo', 'Templates', 'Custom Head Code']) {
expect((q(`button[aria-label="${label}"]`) as HTMLButtonElement).disabled).toBe(true);
}
});
test('shows a loading overlay while the load is in flight', async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
mount();
await flush();
expect(q('[data-testid="load-gate"]')!.textContent).toContain('Loading your site…');
});
test('Retry recovers and re-enables saving', async () => {
fetchMock.mockRejectedValueOnce(new Error('boom'));
fetchMock.mockResolvedValue(jsonResp({ success: true, project: null, rev: null }));
mount();
await flush();
act(() => { q('[data-testid="load-retry"]')!.click(); });
await flush();
expect(q('[data-testid="load-gate"]')).toBeNull();
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(false);
});
test('project:null counts as loaded and autosave then runs', async () => {
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: jsonResp({ success: true }));
mount();
await flush();
expect(q('[data-testid="load-gate"]')).toBeNull();
// Autosave only fires for unsaved changes.
act(() => { guard.markDirty(); });
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveCalls()).toHaveLength(1);
});
});
describe('empty-overwrite guard', () => {
const loadedThen = (saveResponses: unknown[]) => {
const saves = [...saveResponses];
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: (saves.shift() ?? jsonResp({ success: true })));
};
test('Reset Entire Site makes the next save send force_empty (once)', async () => {
loadedThen([]);
mount();
await flush();
act(() => { q('[data-action="open-site-reset"]')!.click(); });
const input = q('[data-testid="site-reset-domain"]') as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
act(() => { setter.call(input, 'example.com'); input.dispatchEvent(new Event('input', { bubbles: true })); });
act(() => { q('[data-action="confirm-site-reset"]')!.click(); });
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
expect(saveBody(0).force_empty).toBe(true);
expect(saveBody(1).force_empty).toBeUndefined();
});
test('a 409 shows the banner, pauses autosave, and Save anyway re-sends with force_empty', async () => {
loadedThen([
jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'refused' }, 409),
jsonResp({ success: true }),
]);
mount();
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
expect(q('[data-testid="empty-save-banner"]')!.textContent).toContain(
'This would replace your saved site with an empty page.',
);
expect(saveBody(0).force_empty).toBeUndefined();
await act(async () => { vi.advanceTimersByTime(60000); });
expect(saveCalls()).toHaveLength(1); // autosave paused
await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); });
await flush();
expect(saveCalls()).toHaveLength(2);
expect(saveBody(1).force_empty).toBe(true);
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
});
test('Undo on the banner runs Craft history undo and dismisses it', async () => {
mockCanUndo.value = true;
loadedThen([jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)]);
mount();
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
act(() => { q('[data-testid="empty-save-undo"]')!.click(); });
expect(undoMock).toHaveBeenCalledTimes(1);
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
});
});