fix(global-sections): Templates, Sitesmith site, Reset site and history Restore refused while a master is open

Each replaced site content under an open master zone: Templates cleared the zone
and committed the template's first section as the new master (rewriting every
instance); replaceAllPages left the zone without committing; history Restore
loaded page content around it. Templates button/command/overflow item and the
history Restore buttons are disabled with a 'Click Done first' notice, and the
handlers (TemplateModal.handleLoad, replaceAllPages -> false, Sitesmith site,
Reset site, restoreSite/restorePage) refuse as a hard guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:26:37 -07:00
1 parent 996c9e92ec
commit ca500d36a9
11 files changed
+171 -33

No files matched your search

+8 -2
View File
@@ -66,6 +66,9 @@ export function useRestoreSnapshot() {
}, [actions]);
const restoreSite = useCallback((snapshot: Snapshot, at: string): boolean => {
// #57: a restore while a master zone is open would load page content into
// the zone (or strand it); Done first.
if (pagesRef.current.isEditingGlobal) { notify({ kind: 'error', message: 'Click Done on the global section you are editing before restoring a version.' }); return false; }
const valid = validateSnapshot(snapshot);
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
const snapPages = snapshotToPages(snapshot);
@@ -93,6 +96,7 @@ export function useRestoreSnapshot() {
}, [extras, updateDesign, markDirty, notify, putOnCanvas]);
const restorePage = useCallback((snapshot: Snapshot, pageIndex: number, at: string): boolean => {
if (pagesRef.current.isEditingGlobal) { notify({ kind: 'error', message: 'Click Done on the global section you are editing before restoring a version.' }); return false; }
const valid = validateSnapshot(snapshot);
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
const snapPage = snapshotToPages(snapshot)[pageIndex];
@@ -124,6 +128,7 @@ interface Pending { id: string; mode: Mode; snapshot: Snapshot; at: string }
export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const { listHistory, getSnapshot } = useHistoryApi();
const { restoreSite, restorePage } = useRestoreSnapshot();
const { isEditingGlobal } = usePages();
const { notify } = useNotices();
const [items, setItems] = useState<HistoryItem[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -197,6 +202,7 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
</button>
</div>
<div className="wsb-history-body">
{isEditingGlobal && <p className="wsb-history-empty" role="status">Click Done on the global section you are editing before restoring a version.</p>}
{loading && <p className="wsb-history-empty">Loading...</p>}
{!loading && items && items.length === 0 && <p className="wsb-history-empty">No earlier versions yet. One is kept each time you save.</p>}
{days.map((g) => (
@@ -210,8 +216,8 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<span className="wsb-history-time" title={fmtExact(it.at)}>{relativeTime(it.at)}</span>
<span className="wsb-history-actions">
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'preview')}>Preview</button>
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-page')}>Restore one page…</button>
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-site')}>Restore site…</button>
<button type="button" disabled={busyId === it.id || isEditingGlobal} onClick={() => begin(it, 'restore-page')}>Restore one page…</button>
<button type="button" disabled={busyId === it.id || isEditingGlobal} onClick={() => begin(it, 'restore-site')}>Restore site…</button>
</span>
</div>
{pending && pending.id === it.id && (
@@ -16,14 +16,16 @@ import { act } from 'react-dom/test-utils';
* for a real-provider test.
* ---------------------------------------------------------------------- */
const replaceAllPages = vi.fn();
// Real contract: true when replaced, false when refused (a global-section master is open).
const replaceAllPages = vi.fn((_pages: { name: string; tree: any }[]) => true);
let mockEditingGlobal = false;
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: {} }),
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {}, isEditingGlobal: mockEditingGlobal }),
}));
vi.mock('../../state/SiteDesignContext', () => ({
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
@@ -210,4 +212,24 @@ describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
expect(container.textContent).toContain('Danger zone');
unmount();
});
test('#57: refused while a global-section master is open (button disabled AND handler guarded)', () => {
mockSiteDomain = 'example.com';
mockEditingGlobal = true;
replaceAllPages.mockClear(); setHeader.mockClear(); setFooter.mockClear(); resetToDefaults.mockClear();
try {
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'); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
act(() => { invokeReactOnClick(confirm); });
expect(replaceAllPages).not.toHaveBeenCalled();
expect(setHeader).not.toHaveBeenCalled();
expect(setFooter).not.toHaveBeenCalled();
expect(resetToDefaults).not.toHaveBeenCalled();
} finally {
mockEditingGlobal = false;
}
});
});
+5 -3
View File
@@ -205,7 +205,7 @@ export function siteResetConfirmMatches(typed: string, domain: string): boolean
export const SiteDesignPanel: React.FC = () => {
const { design, updateDesign, resetToDefaults } = useSiteDesign();
const { replaceAllPages, setHeader, setFooter } = usePages();
const { replaceAllPages, setHeader, setFooter, isEditingGlobal } = usePages();
const { whpConfig } = useEditorConfig();
const { armForceEmptyNextSave } = useSaveGuard();
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
@@ -223,11 +223,13 @@ export const SiteDesignPanel: React.FC = () => {
// `disabled` attribute were ever bypassed (a synthetic click, or a
// future refactor that drops it).
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
// #57: never wipe the site out from under an open global-section master.
if (isEditingGlobal) return;
// The user explicitly confirmed wiping the site, so the next save must
// be allowed to replace the saved site with an empty one.
if (!replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }])) return;
armForceEmptyNextSave();
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader(BLANK_HEADER_TREE);
setFooter(BLANK_FOOTER_TREE);
resetToDefaults();
@@ -499,7 +501,7 @@ export const SiteDesignPanel: React.FC = () => {
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
data-action="confirm-site-reset"
disabled={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
disabled={isEditingGlobal || !siteResetConfirmMatches(siteResetTyped, siteDomain)}
onClick={handleResetSite}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
+13 -3
View File
@@ -47,7 +47,7 @@ const CATEGORY_COLORS: Record<TemplateCategory, string> = {
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
const { actions, query } = useEditor();
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
const { pages, addPage, switchPage, deletePage, editHeader, editFooter, isEditingGlobal } = usePages();
const { updateDesign } = useSiteDesign();
const { armForceEmptyNextSave } = useSaveGuard();
@@ -180,6 +180,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
// while the sequence is still mutating a canvas the dialog just tore down.
const handleLoad = useCallback(async () => {
if (!confirmTemplate) return;
// #57: the live canvas is a global-section master. Clearing it and
// switching away would commit the template's first section as the new
// master and rewrite every instance on the site with it.
if (isEditingGlobal) { setConfirmTemplate(null); return; }
setLoading(true);
const tpl = confirmTemplate;
@@ -245,7 +249,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
if (mountedRef.current) setLoading(false);
onClose();
}
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
}, [confirmTemplate, isEditingGlobal, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
// Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
// as a child of `.topbar`, which (as a flex item with its own z-index)
@@ -306,6 +310,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
))}
</div>
{isEditingGlobal && (
<div role="status" className="template-modal-global-notice" style={{ margin: '0 20px', padding: '10px 12px', borderRadius: 6, background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fbbf24', fontSize: 12 }}>
Click Done on the global section you are editing before loading a template.
</div>
)}
{/* Template Grid */}
<div style={gridContainerStyle}>
<div style={gridStyle}>
@@ -313,7 +323,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
<TemplateCard
key={tpl.id}
template={tpl}
onSelect={() => setConfirmTemplate(tpl)}
onSelect={() => { if (!isEditingGlobal) setConfirmTemplate(tpl); }}
/>
))}
{filtered.length === 0 && (
+15 -1
View File
@@ -34,13 +34,14 @@ import { act } from 'react-dom/test-utils';
// #54: what the pre-publish checks see. Default (no pages) means no checks, so
// every pre-existing test publishes directly.
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null; editingGlobal?: boolean } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
vi.mock('../../state/PageContext', () => ({
usePages: () => ({
pages: checkFixture.pages,
headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null },
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: checkFixture.footerState },
activePageId: checkFixture.activePageId,
isEditingGlobal: !!checkFixture.editingGlobal,
switchPage: vi.fn(),
}),
}));
@@ -146,6 +147,7 @@ afterEach(() => {
checkFixture.liveState = '{}';
checkFixture.activePageId = 'home';
checkFixture.footerState = null;
checkFixture.editingGlobal = false;
openPaletteMock.mockReset();
});
@@ -344,6 +346,18 @@ describe('TopBar search button (#49 integration)', () => {
});
});
describe('TopBar Templates while a global-section master is open (#57)', () => {
test('the Templates button is disabled (and enabled otherwise)', () => {
render();
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Templates"]')!.disabled).toBe(false);
act(() => { root.unmount(); });
container.remove();
checkFixture.editingGlobal = true;
render();
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Templates"]')!.disabled).toBe(true);
});
});
describe('TopBar slow publish', () => {
test('after 30s the button reads "Still publishing..." instead of failing', async () => {
vi.useFakeTimers();
+5 -4
View File
@@ -46,7 +46,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
neverSaved,
} = useSaveGuard();
const canSave = loadState === 'loaded';
const { headerPage, footerPage, pages, activePageId } = usePages();
const { headerPage, footerPage, pages, activePageId, isEditingGlobal } = usePages();
const { design } = useSiteDesign();
const [saving, setSaving] = useState(false);
@@ -372,10 +372,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, when: () => canSave, run: () => handleSave() },
...(isWHP ? [{ id: 'site.publish', title: 'Publish', group: 'site' as const, shortcut: 'mod+shift+p', allowWhileTyping: true, when: () => canSave, run: () => handlePublish() }] : []),
{ id: 'site.preview', title: 'Preview', group: 'site', shortcut: 'mod+p', allowWhileTyping: true, run: () => handlePreview() },
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave, run: ({ openTemplates }) => openTemplates() },
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave && !isEditingGlobal, run: ({ openTemplates }) => openTemplates() },
{ id: 'site.head-code', title: 'Custom head code', group: 'site', when: () => canSave, run: ({ openHeadCode }) => openHeadCode() },
{ id: 'site.report-issue', title: 'Report an issue', group: 'site', run: () => setReportOpen(true) },
], [canSave, isWHP, handleSave, handlePublish, handlePreview]);
], [canSave, isEditingGlobal, isWHP, handleSave, handlePublish, handlePreview]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
@@ -468,6 +468,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
onPreview={handlePreview}
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} disabled={!canSave} />}
editingDisabled={!canSave}
templatesDisabled={isEditingGlobal}
/>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
@@ -531,7 +532,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
<i className="fa fa-repeat" />
</button>
<span className="topbar-divider" />
<button className="topbar-btn" disabled={!canSave} title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
<button className="topbar-btn" disabled={!canSave || isEditingGlobal} title={isEditingGlobal ? 'Click Done on the global section first' : 'Templates'} aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
<i className="fa fa-th-large" /> Templates
</button>
<button
@@ -17,6 +17,8 @@ export interface TopBarOverflowMenuProps {
sitesmithNode: React.ReactNode;
/** Canvas-changing items (Templates, Head Code) are disabled until the site has loaded. */
editingDisabled?: boolean;
/** #57: Templates replace the site; refused while a global-section master is open. */
templatesDisabled?: boolean;
}
/**
@@ -37,6 +39,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
onPreview,
sitesmithNode,
editingDisabled,
templatesDisabled,
}) => {
const { device, setDevice: onDeviceChange } = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
@@ -85,7 +88,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
</div>
</div>
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled} onClick={runAndClose(onOpenTemplates)}>
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled || templatesDisabled} onClick={runAndClose(onOpenTemplates)}>
<i className="fa fa-th-large" aria-hidden="true" /> Templates
</button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onPreview)}>
@@ -19,6 +19,8 @@ import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from '../panels/sections/SectionLibraryModal';
import { Canvas } from '../editor/Canvas';
import { useApplyAiResponse } from '../utils/apply-ai-response';
import { TemplateModal } from '../panels/topbar/TemplateModal';
import { HistoryModal } from '../panels/history/HistoryModal';
/**
* #57 global sections against the REAL Craft editor + PageProvider +
@@ -441,6 +443,77 @@ describe('#57 global sections', () => {
expect(h!.getSerialized()).toContain('AI takeover');
});
test('Templates are refused while a master is open: master and instances untouched', async () => {
mount(<TemplateModal open onClose={() => {}} />);
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const zoneBefore = h!.getSerialized();
expect(document.body.textContent).toContain('Click Done on the global section');
const card = document.body.querySelector<HTMLElement>('[role="button"][tabindex="0"]');
expect(card).not.toBeNull();
h!.act(() => { card!.click(); });
// No confirm dialog -> nothing can be applied to the zone.
expect(Array.from(document.body.querySelectorAll('button')).some((b) => b.textContent === 'Load Template')).toBe(false);
expect(h!.getSerialized()).toBe(zoneBefore);
expect(lib()[libraryId].rev).toBe(1);
});
test('Sitesmith site-scope replace is refused while a master is open', async () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const before = pages.pages.map((p) => p.craftState);
let r: any;
const tree = { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'Heading' }, props: { text: 'AI site' } }] } as any;
await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'site', pages: [{ name: 'Home', tree }], header: { tree } } as any); });
await flush();
expect(r.ok).toBe(false);
expect(r.message).toMatch(/global section/i);
expect(pages.isEditingGlobal).toBe(true);
expect(pages.pages.map((p) => p.craftState)).toEqual(before);
expect(JSON.stringify(pages.headerPage.craftState ?? '')).not.toContain('AI site');
expect(lib()[libraryId].rev).toBe(1);
});
test('history Restore (site and one page) is refused while a master is open; Preview stays available', async () => {
const snap = {
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null } }) }],
};
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
const u = String(url);
const body = u.includes('history_list')
? { success: true, items: [{ id: 'v1', kind: 'save', at: '2026-10-07T17:15:01.042Z', size: 1 }] }
: { success: true, project: snap };
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => body, text: async () => '' } as any;
}));
h = renderEditorHarness({
onRender: RenderNode,
wrap: (tree) => (
<EditorConfigProvider config={WHP_CFG}><SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}</SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider></EditorConfigProvider>
),
});
h.mountChild(<PageProvider><GlobalSectionsProvider><Probe /><HistoryModal open onClose={() => {}} /></GlobalSectionsProvider></PageProvider>);
h.act(() => { guard.setLoadState('loaded'); });
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
await flush();
const buttons = () => Array.from(document.body.querySelectorAll<HTMLButtonElement>('.wsb-history-row button'));
const byText = (t: string) => buttons().find((b) => b.textContent === t)!;
expect(byText('Preview').disabled).toBe(false);
expect(byText('Restore site…').disabled).toBe(true);
expect(byText('Restore one page…').disabled).toBe(true);
expect(document.body.textContent).toContain('Click Done on the global section');
});
test('remove is refused while instances exist, allowed once none are left', () => {
mount();
const sec = addPromo();
+10 -4
View File
@@ -97,7 +97,8 @@ interface PageContextValue {
*/
setActivePageIdDirect: (pageId: string) => void;
/** AI helpers — replace entire site or page with a new tree */
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
/** False (nothing replaced) while a global-section master is open (#57). */
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => boolean;
/** False (nothing changed) while a global-section master is on the canvas (#57). */
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean;
setHeader: (tree: SerializedTreeNode) => void;
@@ -283,7 +284,7 @@ const PageContext = createContext<PageContextValue>({
setFooterCraftState: () => {},
setPagesCraftState: () => {},
setActivePageIdDirect: () => {},
replaceAllPages: () => {},
replaceAllPages: () => false,
replaceCurrentPage: () => false,
setHeader: () => {},
setFooter: () => {},
@@ -897,8 +898,12 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
* Stores each page's serialized state without touching the live canvas
* (the canvas still shows the currently active page — call switchPage() if needed).
*/
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
if (newPages.length === 0) return;
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]): boolean => {
if (newPages.length === 0) return false;
// #57: leaving a master zone without committing it would silently drop
// the master edits (and strand the zone's return target). Refuse; the
// caller tells the user to click Done first.
if (isGlobalZoneId(activePageIdRef.current)) return false;
// Track slugs as they're assigned so later pages dedupe against earlier
// ones in the same batch (e.g. the AI generating two "About" pages).
const seenSlugs: string[] = [];
@@ -921,6 +926,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setActivePageId(built[0].id);
activePageIdRef.current = built[0].id;
loadState(firstState, EMPTY_CANVAS);
return true;
}, [loadState]);
/**
+8 -1
View File
@@ -58,7 +58,14 @@ export function useApplyAiResponse() {
if (resp.type === 'replace') {
if (resp.scope === 'site') {
pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })));
if (!pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })))) {
return {
ok: false,
message: pages.isEditingGlobal
? 'You are editing a global section. Click Done, then ask again to replace the site.'
: 'Nothing to apply.',
};
}
if (resp.header) pages.setHeader(resp.header.tree);
if (resp.footer) pages.setFooter(resp.footer.tree);
return { ok: true };
+6 -12
View File
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { openSandboxedPreview, PREVIEW_SANDBOX } from './sandboxed-preview';
/** A real (detached) document standing in for the about:blank popup. */
@@ -52,16 +50,12 @@ describe('openSandboxedPreview (customer head code never runs in the panel origi
});
describe('no same-origin path for composed-page HTML remains in the source', () => {
const root = join(__dirname, '..');
const files: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) && !p.includes('test-utils')) files.push(p);
}
};
walk(root);
// Every non-test source file under src/, as raw text (Vite glob; no @types/node here).
const sources = import.meta.glob(['../**/*.{ts,tsx}', '!../**/*.test.{ts,tsx}', '!../test-utils/**'], { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
const files = Object.keys(sources);
const readFileSync = (f: string, _enc: string) => sources[f];
const root = '..';
expect(files.length).toBeGreaterThan(100);
test('nothing builds a text/html blob, document.writes, or opens a blob: URL', () => {
const offenders = files.filter((f) => {