- 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>
262 lines
9.9 KiB
TypeScript
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();
|
|
});
|
|
});
|