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(); }); });