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();
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
/** React tracks a controlled ``'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 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();
});
});