2026-08-09 10:17:53 -07:00
|
|
|
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 }),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-08-09 10:30:20 -07:00
|
|
|
import { SiteDesignPanel, siteResetConfirmMatches } from './SiteDesignPanel';
|
2026-08-09 10:17:53 -07:00
|
|
|
|
|
|
|
|
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 }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 10:30:20 -07:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 10:17:53 -07:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 10:30:20 -07:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 10:17:53 -07:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|