Files
site-builder/craft/src/panels/right/SiteDesignPanel.reset.test.tsx
T
shadowdaoandClaude Opus 5 aba0d187d7 fix(site-builder): make Reset Entire Site's guard load-bearing in the handler
Two Important review findings on the previous commit (1a01834):

1. handleResetSite ran unconditionally -- the confirm button's `disabled`
   attribute was the only thing standing between a mismatched/empty typed
   value and a full site wipe. Extracted the match check into a single
   exported pure predicate, siteResetConfirmMatches(typed, domain), used
   for the button's disabled/cursor/opacity (previously three duplicated
   inline comparisons) AND as the first line of handleResetSite itself,
   which now returns early if it doesn't hold. An empty domain is rejected
   outright (`!!domain &&` short-circuits) so the guard holds even if the
   handler were ever reached with no configured domain, independent of the
   entry point being hidden.

2. The dialog said "design tokens" but resetToDefaults() also wipes
   headCode (analytics/search-console/third-party scripts) and favicon --
   neither is one of the 17 documented design properties, so a user had no
   reason to read them as included. Copy now names both explicitly.
   Re-verified every remaining claim in the paragraph against what the
   handler actually does (page/header/footer replacement, no undo, no
   publish call, images untouched, 30000ms auto-save) -- all still hold.

Verified load-bearing by temporarily reverting each guard in place (no git
stash -- shared across worktrees/sessions per review feedback) and
confirming the corresponding test fails: dropping the !!domain check broke
the empty-domain unit test; removing the handleResetSite check broke a new
test that invokes the confirm button's React onClick directly (bypassing
both the disabled attribute and react-dom's own disabled-click suppression,
which independent investigation confirmed blocks a plain DOM `.disabled =
false; .click()`/dispatchEvent bypass -- pulling onClick off the element's
stashed __reactProps$ key was the only way to actually exercise the
handler's own guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:30:20 -07:00

214 lines
9.8 KiB
TypeScript

import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* -------------------------------------------------------------------------
* Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that
* confirming calls the right context functions with tree-shaped arguments,
* and that standalone mode (empty siteDomain) hides the entry point
* entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/
* `resetToDefaults` implementations (and whether the blank trees this
* component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are
* covered separately, WITHOUT mocks, in
* `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and
* file-scoped, so it can't be selectively "undone" partway through one file
* for a real-provider test.
* ---------------------------------------------------------------------- */
const replaceAllPages = vi.fn();
const setHeader = vi.fn();
const setFooter = vi.fn();
const resetToDefaults = vi.fn();
let mockSiteDomain = 'example.com';
vi.mock('../../state/PageContext', () => ({
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
}));
vi.mock('../../state/SiteDesignContext', () => ({
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
DEFAULT_SITE_DESIGN: {},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }),
}));
import { SiteDesignPanel, siteResetConfirmMatches } from './SiteDesignPanel';
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<SiteDesignPanel />);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
/** 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. Using the real native setter (same
* pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent
* `input` event register as a genuine value change. */
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 }));
}
/**
* Reach past React's OWN disabled-button protection to grab the `onClick`
* function it attached to a DOM node, and call it directly.
*
* Verified empirically (see task-15 follow-up investigation) that neither
* `el.disabled = false; el.click()` nor a raw `dispatchEvent(new
* MouseEvent('click', ...))` actually invokes a React `onClick` handler once
* React has rendered the element with `disabled` truthy: react-dom's event
* system special-cases form controls and refuses to dispatch synthetic
* click/similar events against its own last-rendered `disabled` prop,
* regardless of what the live DOM property says. That's a second, redundant
* safety net on top of the native browser behavior -- but it also means a
* pure DOM-level "disabled bypass" can't reach `handleResetSite` at all
* through the button, and so can't exercise the handler's OWN guard the way
* this test needs to. Pulling `onClick` off the fiber's stashed props
* (`__reactProps$...`, the same object React itself calls into) and
* invoking it directly is what actually reaches `handleResetSite` -- the
* same code path a differently-wired future trigger (a keyboard shortcut, a
* second button, a copy-paste bug) would also go through without passing
* back through the `disabled` gate at all.
*/
function invokeReactOnClick(el: HTMLElement): void {
const propsKey = Object.keys(el).find((k) => k.startsWith('__reactProps$'));
const onClick = propsKey ? (el as unknown as Record<string, { onClick?: () => void }>)[propsKey].onClick : undefined;
if (!onClick) throw new Error('no React onClick prop found on element');
onClick();
}
describe('siteResetConfirmMatches -- the guard predicate itself', () => {
test('matches only an exact (trimmed) domain match', () => {
expect(siteResetConfirmMatches('example.com', 'example.com')).toBe(true);
expect(siteResetConfirmMatches('example.co', 'example.com')).toBe(false);
expect(siteResetConfirmMatches('', 'example.com')).toBe(false);
expect(siteResetConfirmMatches(' example.com ', 'example.com')).toBe(true);
});
test('an empty domain never matches, even against an empty typed value -- ' +
'this is the guard that must hold even if the (currently hidden) entry ' +
'point were ever reachable in standalone mode', () => {
expect(siteResetConfirmMatches('', '')).toBe(false);
expect(siteResetConfirmMatches(' ', '')).toBe(false);
});
});
describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
test('the confirm button is disabled until the domain is typed exactly', () => {
mockSiteDomain = 'example.com';
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'example.co'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
act(() => { setInputValue(input, 'example.com'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
unmount();
});
test('confirming blanks pages, header, footer and design tokens', () => {
mockSiteDomain = 'example.com';
replaceAllPages.mockClear();
setHeader.mockClear();
setFooter.mockClear();
resetToDefaults.mockClear();
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'example.com'); });
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
expect(replaceAllPages).toHaveBeenCalledTimes(1);
const pagesArg = replaceAllPages.mock.calls[0][0];
expect(pagesArg).toHaveLength(1);
expect(pagesArg[0].name).toBe('Home');
// The replacement is a SerializedTreeNode (recursive tree), not a flat
// craft-state entry: a real Container root with no children.
expect(pagesArg[0].tree.type.resolvedName).toBe('Container');
expect(pagesArg[0].tree.nodes).toEqual([]);
expect(setHeader).toHaveBeenCalledTimes(1);
expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(setFooter).toHaveBeenCalledTimes(1);
expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(resetToDefaults).toHaveBeenCalledTimes(1);
// The panel closes its own dialog back up after confirming.
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
unmount();
});
test('the handler itself refuses a non-matching value, even when its onClick is invoked ' +
'directly (bypassing `disabled` and React\'s own disabled-click suppression) -- proves ' +
'the guard is load-bearing in handleResetSite, not just a UI affordance a different ' +
'trigger path could route around', () => {
mockSiteDomain = 'example.com';
replaceAllPages.mockClear();
setHeader.mockClear();
setFooter.mockClear();
resetToDefaults.mockClear();
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'not-the-domain'); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true); // sanity: the UI affordance is still doing its job too
act(() => { invokeReactOnClick(confirm); });
expect(replaceAllPages).not.toHaveBeenCalled();
expect(setHeader).not.toHaveBeenCalled();
expect(setFooter).not.toHaveBeenCalled();
expect(resetToDefaults).not.toHaveBeenCalled();
// The dialog is still open -- handleResetSite returned early before
// reaching its own close-the-dialog cleanup.
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeTruthy();
unmount();
});
test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' +
'while non-standalone mode shows it -- an empty typed input must never trivially ' +
'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' +
'so this fails if the whole feature -- not just the guard -- were ever removed.', () => {
mockSiteDomain = '';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull();
expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull();
expect(container.textContent).not.toContain('Danger zone');
expect(container.textContent).not.toContain('Reset Entire Site');
unmount();
mockSiteDomain = 'example.com';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
expect(container.textContent).toContain('Danger zone');
unmount();
});
});