feat(site-builder): add domain-confirmed Reset Entire Site

Adds a danger-zone escape hatch below SiteDesignPanel's existing "Reset to
Defaults": blanks every page down to one empty Home, blanks the header and
footer, and resets all design tokens. Guarded by typing the exact site
domain to arm the confirm button.

The brief's setHeader('')/setFooter('') calls were wrong -- both take a
SerializedTreeNode (same tree shape as replaceAllPages), not a craft-state
string; passing '' would have hit treeToCraftState's sanitizeAiTree and
silently fallen back to a generic div-shaped empty canvas instead of a
proper header/footer. Built BLANK_HEADER_TREE/BLANK_FOOTER_TREE (tagged
header/footer) alongside the brief's page tree, and dropped the brief's
literal's extraneous flat-state fields (isCanvas/displayName/custom/hidden/
linkedNodes) that made it need an `as any` cast -- sanitizeAiTree/
flattenTreeForCraft only ever read type/props/nodes.

In standalone mode (no WHP_CONFIG) siteDomain is '', so the entire danger
zone -- not just the button -- is hidden behind `siteDomain &&`, closing off
the empty-string-trivially-matches guard bypass.

Dialog copy states the reset is undoable... is NOT undoable, and that
auto-save (confirmed exactly 30s via TopBar.tsx's setInterval) turns the
blank canvas into the saved draft shortly after, so no false safety-net
claim is made.

Verified load-bearing: reverted the implementation via git stash and
confirmed 3 of 4 new tests fail (the 4th, an absence-only standalone-mode
check, passed vacuously on first draft -- rewritten into a same-test
contrast against the non-standalone case, which does fail on revert).
Split into a second mock-free integration test file
(SiteDesignPanel.reset.integration.test.tsx) after vi.mock's file-scoped
hoisting made a same-file vi.doUnmock silently keep using the mocks --
it exercises the real PageProvider/SiteDesignProvider/treeToCraftState
pipeline end to end via the real editorHarness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:17:53 -07:00
co-authored by Claude Opus 5
parent bfcf6278e9
commit 1a01834068
3 changed files with 368 additions and 0 deletions
@@ -0,0 +1,97 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { PageProvider, usePages } from '../../state/PageContext';
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { SiteDesignPanel } from './SiteDesignPanel';
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
* a plain `input.value = x` assignment doesn't go through it, so React never
* sees the change and skips onChange. */
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Real-provider integration test -- NO mocks. Exercises the actual
* `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations,
* which route the component's blank trees through the real
* `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see
* `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the
* task brief calls out specifically: a tree shape that fails sanitisation
* would silently fall back to an empty canvas (masking a bug) rather than
* throwing, so asserting only on mock call arguments (as
* `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape --
* the mocked assertions there only prove the component PASSED a
* `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't
* prove that object is actually valid input to the real pipeline.
*/
describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => {
test('confirming produces a real, valid single blank Home page + header + footer + default design', () => {
let pageCtx: ReturnType<typeof usePages> | null = null;
let designCtx: ReturnType<typeof useSiteDesign> | null = null;
const Probe: React.FC = () => {
pageCtx = usePages();
designCtx = useSiteDesign();
return null;
};
const harness = renderEditorHarness();
harness.mountChild(
<EditorConfigProvider config={{
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
}}>
<PageProvider>
<SiteDesignProvider>
<Probe />
<SiteDesignPanel />
</SiteDesignProvider>
</PageProvider>
</EditorConfigProvider>,
);
// Dirty the design tokens first so resetToDefaults has something to undo.
harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); });
expect(designCtx!.design.primaryColor).toBe('#000000');
harness.act(() => {
(harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click();
});
const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
harness.act(() => { setInputValue(input, 'example.com'); });
harness.act(() => {
(harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click();
});
// Exactly one page, "Home", landing-page slug -- and its stored
// craftState is real, parseable Craft.js state with a Container ROOT
// and no children (i.e. sanitizeAiTree did NOT reject the tree and fall
// back to a silent empty canvas for the wrong reason -- it's genuinely
// ROOT with zero nodes because that's what we asked for).
expect(pageCtx!.pages).toHaveLength(1);
expect(pageCtx!.pages[0].name).toBe('Home');
expect(pageCtx!.pages[0].slug).toBe('index');
const homeState = JSON.parse(pageCtx!.pages[0].craftState!);
expect(homeState.ROOT.type.resolvedName).toBe('Container');
expect(homeState.ROOT.nodes).toEqual([]);
const headerState = JSON.parse(pageCtx!.headerPage.craftState!);
expect(headerState.ROOT.type.resolvedName).toBe('Container');
expect(headerState.ROOT.nodes).toEqual([]);
expect(headerState.ROOT.props.tag).toBe('header');
const footerState = JSON.parse(pageCtx!.footerPage.craftState!);
expect(footerState.ROOT.type.resolvedName).toBe('Container');
expect(footerState.ROOT.nodes).toEqual([]);
expect(footerState.ROOT.props.tag).toBe('footer');
// Design tokens are back to the defaults.
expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN);
harness.unmount();
});
});