- 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>
436 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|