Files
site-builder/craft/src/sitesmith/replace-plan.test.ts
T
shadowdaoandClaude Opus 5.5 2dcf8b7feb fix(sitesmith): a partial whole-site build never offers a bare destructive "Replace site"
Live case 2 (2026.11.8): the daily limit stopped a site build after page 1, the dialog still
said "Replace your entire site?" and accepting deleted the owner's other page. The server now
reports site_build {planned, built, missing}; when pages are missing the confirm becomes
"Sitesmith built only N of M pages" with:
- "Add these pages" (default focus): each built page replaces the page at the same address,
  new ones are appended, every other page is kept (PageContext mergeAiPages / addAiPages), and
  the new header is repaired to link the kept pages too;
- "Replace site and delete K pages" with the names it deletes (only when K > 0);
- Cancel.
A complete site build keeps "Replace your entire site?" and now names the pages it deletes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:29:56 -08:00

211 lines
14 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import { explicitSiteRequest, isDefaultChrome, planReplace } from './replace-plan';
import { DEFAULT_HEADER_STATE } from '../state/PageContext';
import type { SitesmithResponse } from '../types/sitesmith';
const tree = (name = 'Container') => ({ type: { resolvedName: name }, props: {}, nodes: [] }) as any;
const siteResp = (n = 1): SitesmithResponse => ({
type: 'replace', scope: 'site', message: 'Built it',
pages: Array.from({ length: n }, (_, i) => ({ name: i ? `P${i}` : 'Home', tree: tree() })),
header: { tree: tree('Navbar') }, footer: { tree: tree('Footer') },
});
describe('explicitSiteRequest', () => {
test.each([
'Build me a website for my bakery',
'Build my website',
'Create a new site for my plumbing business',
'Redo the whole site',
'Rebuild the entire website in a darker style',
'Make a 4 page site with home, about, menu and contact',
'Update all pages to use the new colours',
'Change the font on every page',
'Build my bakery website',
])('site: %s', (t) => expect(explicitSiteRequest(t)).toBe(true));
test.each([
'Build my home page following the layout of the attached mockup.',
'Build the home page for my site.',
'Build the home page for my site. Use the site I like as inspiration.',
'Redo the home page',
'Make this page look more premium',
'Add a pricing section',
'',
])('page: %s', (t) => expect(explicitSiteRequest(t)).toBe(false));
});
describe('isDefaultChrome', () => {
const header = (mut: (n: any) => void) => { const s = JSON.parse(DEFAULT_HEADER_STATE); mut(s); return JSON.stringify(s); };
test('the seed header + no footer is default, also after pages were added to the nav', () => {
expect(isDefaultChrome(DEFAULT_HEADER_STATE, null)).toBe(true);
expect(isDefaultChrome(header((s) => s['header-navbar'].props.links.splice(1, 0, { text: 'About', href: '/about', source: 'page', pageId: 'p2' })), null)).toBe(true);
expect(isDefaultChrome(null, null)).toBe(true);
});
test('any owner change makes it custom', () => {
expect(isDefaultChrome(header((s) => { s['header-navbar'].props.logoText = 'Acme'; }), null)).toBe(false);
expect(isDefaultChrome(header((s) => { s['header-navbar'].props.logoType = 'image'; s['header-navbar'].props.logoImage = 'assets/logo.png'; }), null)).toBe(false);
expect(isDefaultChrome(header((s) => { s['header-navbar'].props.links[1].text = 'Book now'; }), null)).toBe(false);
expect(isDefaultChrome(header((s) => { s.ROOT.nodes.push('x'); s.x = { type: { resolvedName: 'TextBlock' }, props: {}, nodes: [] }; }), null)).toBe(false);
const footer = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['f'] }, f: { type: { resolvedName: 'Footer' }, props: { text: '© 2026 Acme Ltd' }, nodes: [] } });
expect(isDefaultChrome(DEFAULT_HEADER_STATE, footer)).toBe(false);
const seedFooter = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['f'] }, f: { type: { resolvedName: 'Footer' }, props: { text: '© 2026 MySite. All rights reserved.' }, nodes: [] } });
expect(isDefaultChrome(DEFAULT_HEADER_STATE, seedFooter)).toBe(true);
expect(isDefaultChrome('{not json', null)).toBe(false);
});
});
describe('planReplace', () => {
test('eval case: "Build my home page" answered with scope site -> a PAGE replace, no site confirm', () => {
const p = planReplace(siteResp(1), { userText: 'Build my home page following the layout of the attached mockup.', chromeDefault: false });
expect(p.confirm).toBeNull();
expect(p.resp).toMatchObject({ type: 'replace', scope: 'page', pages: [{ name: 'Home' }] });
expect((p.resp as any).header).toBeUndefined();
expect((p.resp as any).footer).toBeUndefined();
expect(p.note).toMatch(/only this page/i);
});
test('page request + site answer + default chrome -> page replace that ASKS about header/footer', () => {
const p = planReplace(siteResp(3), { userText: 'Build my home page', chromeDefault: true });
expect(p.confirm).toBe('chrome');
expect(p.resp).toMatchObject({ scope: 'page', pages: [{ name: 'Home' }], header: { tree: { type: { resolvedName: 'Navbar' } } } });
expect((p.resp as any).pages).toHaveLength(1);
});
test('explicit site request keeps the site replace and its confirm', () => {
const p = planReplace(siteResp(3), { userText: 'Build me a website for my bakery', chromeDefault: false });
expect(p.confirm).toBe('site');
expect(p.resp).toEqual(siteResp(3));
});
test('page answer with header/footer: asked only when chrome is default, otherwise dropped', () => {
const resp: SitesmithResponse = { ...(siteResp(1) as any), scope: 'page' };
expect(planReplace(resp, { userText: 'Build my home page', chromeDefault: true }).confirm).toBe('chrome');
const kept = planReplace(resp, { userText: 'Build my home page', chromeDefault: false });
expect(kept.confirm).toBeNull();
expect((kept.resp as any).header).toBeUndefined();
expect(kept.note).toMatch(/kept your header and footer/i);
});
test('plain page / section / patch answers pass through untouched', () => {
const page: SitesmithResponse = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: tree() }] };
expect(planReplace(page, { userText: 'x', chromeDefault: true })).toEqual({ resp: page, confirm: null });
const patch: SitesmithResponse = { type: 'patch', message: 'm', ops: [] };
expect(planReplace(patch, { userText: 'x', chromeDefault: true })).toEqual({ resp: patch, confirm: null });
});
});
import { fullBleedPageRoots } from './replace-plan';
describe('fullBleedPageRoots (AI-built pages have no white frame around every section)', () => {
const page = (rootProps: any): SitesmithResponse => ({ type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: rootProps, nodes: [] } as any }] });
test('a root Container without padding gets padding 0 (the Container default is 20px)', () => {
const out = fullBleedPageRoots(page({ tag: 'div', node_id: 'ai-root-1' })) as any;
expect(out.pages[0].tree.props.style).toEqual({ padding: '0px' });
const out2 = fullBleedPageRoots(page({ tag: 'div', style: { backgroundColor: '#000' } })) as any;
expect(out2.pages[0].tree.props.style).toEqual({ padding: '0px', backgroundColor: '#000' });
});
test('padding the model set on purpose is kept; non-Container roots, sections and patches are untouched', () => {
expect((fullBleedPageRoots(page({ style: { padding: '40px' } })) as any).pages[0].tree.props.style).toEqual({ padding: '40px' });
expect((fullBleedPageRoots(page({ style: { paddingTop: '8px' } })) as any).pages[0].tree.props.style).toEqual({ paddingTop: '8px' });
const sec: SitesmithResponse = { ...(page({}) as any), scope: 'section' };
expect(fullBleedPageRoots(sec)).toBe(sec);
const patch: SitesmithResponse = { type: 'patch', message: 'm', ops: [] };
expect(fullBleedPageRoots(patch)).toBe(patch);
const hero: SitesmithResponse = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: { type: { resolvedName: 'HeroSimple' }, props: {}, nodes: [] } as any }] };
expect((fullBleedPageRoots(hero) as any).pages[0].tree.props.style).toBeUndefined();
});
test('site replace: every page root; the input is not mutated', () => {
const r: SitesmithResponse = { type: 'replace', scope: 'site', message: 'm', pages: [0, 1].map((i) => ({ name: `P${i}`, tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [] } as any })) };
const out = fullBleedPageRoots(r) as any;
expect(out.pages.map((p: any) => p.tree.props.style)).toEqual([{ padding: '0px' }, { padding: '0px' }]);
expect((r as any).pages[0].tree.props.style).toBeUndefined();
});
});
import { missingUploadedImageNote } from './replace-plan';
describe('missingUploadedImageNote (the brief requires an uploaded image when there are uploads)', () => {
const pageWith = (props: any): SitesmithResponse => ({ type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'ImageBlock' }, props, nodes: [] }] } as any }] });
test('uploads offered but none used -> an honest note; used (src or bgImage) or no uploads -> none', () => {
expect(missingUploadedImageNote(pageWith({ src: 'https://placehold.co/800x600' }), 2)).toMatch(/did not use any of your 2 uploaded images/);
expect(missingUploadedImageNote(pageWith({ src: 'assets/team.jpg' }), 2)).toBeNull();
expect(missingUploadedImageNote(pageWith({ bgImage: 'assets/team.jpg' }), 1)).toBeNull();
expect(missingUploadedImageNote(pageWith({ src: 'https://placehold.co/800x600' }), 0)).toBeNull();
expect(missingUploadedImageNote({ type: 'patch', message: 'm', ops: [] }, 3)).toBeNull();
});
});
describe('planReplace: header link repair', () => {
const mk = (scope: 'page' | 'site'): any => ({
type: 'replace', scope, message: 'm',
pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: [] } }],
header: { tree: { type: { resolvedName: 'Navbar' }, props: { links: [{ text: 'Home', href: '/' }] }, nodes: [] } },
});
const pages: any[] = [{ id: 'h', name: 'Home', slug: 'index', craftState: null }, { id: 'a', name: 'About', slug: 'about', craftState: null }];
test('page scope + chrome confirm: header nav repaired and the miss count reported', () => {
const plan = planReplace(mk('page'), { userText: 'redo this page', chromeDefault: true, pages });
expect(plan.confirm).toBe('chrome');
expect(plan.addedLinks).toEqual(['About']);
expect((plan.resp as any).header.tree.props.followPages).toBe(true);
});
test('site answer downgraded to a page is repaired as a page', () => {
const plan = planReplace(mk('site'), { userText: 'build my home page', chromeDefault: true, pages });
expect(plan.addedLinks).toEqual(['About']);
});
test('explicit whole-site replace: header left as the model wrote it', () => {
const r = mk('site');
const plan = planReplace(r, { userText: 'build the whole site', chromeDefault: true, pages });
expect(plan.confirm).toBe('site');
expect(plan.resp).toBe(r);
expect(plan.addedLinks).toBeUndefined();
});
});
// ---- round 4 item 1: a PARTIAL whole-site build (live case 2: "Built 1 page: Home", About/Brands/Contact missing)
// must never be offered as a bare "Replace site" -- that deleted the owner's other pages.
import { mergeAiPages } from '../state/PageContext';
describe('round 4: partial whole-site builds', () => {
const tree = { type: { resolvedName: 'Container' }, props: {}, nodes: [] } as any;
const existing: any[] = [
{ id: 'home', name: 'Home', slug: 'index', craftState: 'H', seo: { title: 'Old' } },
{ id: 'p2', name: 'MG TEST (delete me)', slug: 'mobile-guard-test', craftState: 'M' },
{ id: 'p3', name: 'About', slug: 'about', craftState: 'A' },
];
const siteResp = (names: string[]): SitesmithResponse => ({ type: 'replace', scope: 'site', message: 'Built 1 page: Home.', pages: names.map((name) => ({ name, tree })),
header: { tree: { type: { resolvedName: 'Navbar' }, props: { links: [{ text: 'Home', href: '/' }] }, nodes: [] } as any } });
test('mergeAiPages (pure): built pages replace the page at the same address, new ones are appended, the rest are kept', () => {
let n = 0;
const m = mergeAiPages(existing, [{ name: 'Home', craftState: 'NEW-H' }, { name: 'About', craftState: 'NEW-A' }, { name: 'Brands', craftState: 'NEW-B' }], () => `new${++n}`);
expect(m.pages.map((p) => [p.id, p.name, p.slug, p.craftState])).toEqual([
['home', 'Home', 'index', 'NEW-H'], ['p2', 'MG TEST (delete me)', 'mobile-guard-test', 'M'], ['p3', 'About', 'about', 'NEW-A'], ['new1', 'Brands', 'brands', 'NEW-B']]);
expect(m.pages[0].seo).toEqual({ title: 'Old' }); // the owner's page settings stay
expect(m.firstId).toBe('home');
expect(m.replaced).toEqual(['Home', 'About']);
expect(m.added).toEqual(['Brands']);
expect(m.kept).toEqual(['MG TEST (delete me)']);
expect(existing[0].craftState).toBe('H'); // input not mutated
});
test('mergeAiPages: an empty site gets the first page as index/home', () => {
const m = mergeAiPages([], [{ name: 'Home', craftState: 'X' }, { name: 'Contact', craftState: 'Y' }], () => 'n1');
expect(m.pages.map((p) => [p.id, p.slug])).toEqual([['home', 'index'], ['n1', 'contact']]);
});
test('missing pages -> confirm "site-partial" with explicit counts, never the bare site confirm', () => {
const plan = planReplace(siteResp(['Home']), { userText: 'Build me a website for OLS Digital', chromeDefault: true, pages: existing,
siteBuild: { planned: ['Home', 'About', 'Brands', 'Contact'], built: ['Home'], missing: ['About', 'Brands', 'Contact'] } });
expect(plan.confirm).toBe('site-partial');
expect(plan.impact).toEqual({ planned: 4, built: ['Home'], missing: ['About', 'Brands', 'Contact'],
replaced: ['Home'], added: [], kept: ['MG TEST (delete me)', 'About'] });
});
test('complete site build -> "site" confirm, still with the pages a replace would delete', () => {
const plan = planReplace(siteResp(['Home', 'Contact']), { userText: 'Build me a website', chromeDefault: true, pages: existing,
siteBuild: { planned: ['Home', 'Contact'], built: ['Home', 'Contact'], missing: [] } });
expect(plan.confirm).toBe('site');
expect(plan.impact?.kept).toEqual(['MG TEST (delete me)', 'About']);
});
test('an older server (no site_build) keeps the whole-site confirm', () => {
expect(planReplace(siteResp(['Home']), { userText: 'Build me a website', chromeDefault: true, pages: existing }).confirm).toBe('site');
});
test('addPagesPlan: the header gets links to the kept pages (followPages), the built pages are untouched', async () => {
const { addPagesResponse } = await import('./replace-plan');
const out = addPagesResponse(siteResp(['Home']), existing) as any;
expect(out.header.tree.props.followPages).toBe(true);
expect(out.header.tree.props.links.map((l: any) => l.href)).toEqual(['/', '/mobile-guard-test', '/about']);
expect(out.pages).toEqual((siteResp(["Home"]) as any).pages);
});
});