Sitesmith modal: flow tests (phases, cancel, resume, errors, vision off, sites/assets payload, self-review round trip)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:43:23 -08:00
1 parent 784d85346e
commit 8a481a87d8
1 file changed
+267
@@ -0,0 +1,267 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { SaveGuardProvider } from '../../state/SaveGuardContext';
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
import { NoticeProvider } from '../../state/NoticeContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { SitesmithModal } from './SitesmithModal';
import { __resetProfileWizardForTests, ProfileWizardHost } from './ProfileWizard';
import { __resetCommandsForTests } from '../../editor/commands';
vi.mock('../../utils/self-review-capture', async (orig) => {
const real = await orig<typeof import('../../utils/self-review-capture')>();
return { ...real, captureHtml: vi.fn() };
});
import { captureHtml } from '../../utils/self-review-capture';
const CFG = { user: 'u', apiUrl: '/api/site-builder.php', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'x', backUrl: '/', isRoot: false };
const PROFILE = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains'], area: 'Austin', tone: 'bold' };
const node = (type: string, props: any, extra: any = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra,
});
const INITIAL = JSON.stringify({
ROOT: node('Container', { style: {}, tag: 'div' }, { nodes: ['sec'] }),
sec: node('Container', { style: { backgroundColor: '#1e3a8a' }, tag: 'section' }, { parent: 'ROOT', nodes: ['h1'] }),
h1: node('Heading', { text: 'Original heading', level: 'h2' }, { parent: 'sec' }),
});
const PNG_OK = () => new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])], 'm.png', { type: 'image/png' });
let h: EditorHarness;
let extras: ReturnType<typeof useProjectExtras>;
const Spy: React.FC = () => { extras = useProjectExtras(); return null; };
const flush = (ms = 0) => h.act(async () => { await new Promise((r) => setTimeout(r, ms)); });
interface Net {
starts: any[]; cancels: any[]; deleted: string[]; uploads: number;
polls: any[]; caps: any; history: any; entitlement: any; assets: any[]; refs: any[]; startFail: boolean; uploadFail: boolean;
}
let net: Net;
const json = (v: unknown, status = 200) => ({ status, json: async () => v });
function installFetch() {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
if (net.startFail && url.includes('action=start')) throw new TypeError('NetworkError when attempting to fetch resource.');
if (url.includes('action=start')) { net.starts.push(JSON.parse(init.body)); return json({ ok: true, job_id: 'j' + net.starts.length, status: 'running', phase: 'queued' }); }
if (url.includes('action=poll')) { const p = net.polls.length > 1 ? net.polls.shift() : net.polls[0]; if (p.status && p.status !== 'running') net.history = { ok: true, messages: [], active_job: null }; return json(p); }
if (url.includes('action=cancel')) { net.cancels.push(JSON.parse(init.body)); net.polls = [{ ok: true, status: 'cancelled', phase: 'done', result: { ok: false, status: 'CANCELLED', message: 'Cancelled' } }]; return json({ ok: true, status: 'cancelled' }); }
if (url.includes('action=entitlement')) return json(net.entitlement);
if (url.includes('action=capabilities')) return json(net.caps);
if (url.includes('action=history')) return json(net.history);
if (url.includes('action=refs_list')) return json({ ok: true, references: net.refs, images: [] });
if (url.includes('action=assets')) return json({ ok: true, assets: net.assets });
if (url.includes('action=ref_image_upload')) { net.uploads++; return net.uploadFail ? json({ ok: false, error: 'x', message: 'Upload failed' }, 500) : json({ ok: true, image: { id: 'f'.repeat(32), kind: 'mockup', name: 'self-review-render.jpg', width: 1280, height: 900, size: 5000 } }); }
if (url.includes('action=ref_image_delete')) { net.deleted.push(JSON.parse(init.body).id); return json({ ok: true }); }
return json({ ok: true, messages: [] });
}));
}
const VISION_ON = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: true }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } };
const VISION_OFF = { ...VISION_ON, vision: { available: false, source: 'none', message: 'This server has no vision-capable model.' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false } };
const done = (response: any, extra: any = {}) => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response, ...extra } });
const replaceHeading = (text: string, color?: string) => ({ type: 'patch', message: 'ok', ops: [{ op: 'replace_node', node_id: 'sec', tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', style: { backgroundColor: '#1e3a8a' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { text, level: 'h2', style: color ? { color } : {} }, nodes: [] }] } }] });
async function mount(opts: { profile?: boolean } = {}) {
h = renderEditorHarness({
initialState: INITIAL,
wrap: (tree) => (
<EditorConfigProvider config={CFG as any}>
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider><SitesmithProvider>{tree}</SitesmithProvider></SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
</EditorConfigProvider>
),
});
h.mountChild(<><Spy /><ProfileWizardHost /><SitesmithModal onClose={() => {}} target={null} /></>);
if (opts.profile !== false) h.act(() => extras.set('sitesmithProfile', PROFILE));
await flush();
await flush();
}
const ta = () => document.querySelector('textarea.sitesmith-textarea') as HTMLTextAreaElement | null;
const type = (el: HTMLTextAreaElement, v: string) => h.act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
const sendText = async (t: string) => { await type(ta()!, t); await h.act(async () => { (ta()!.parentElement!.querySelector('button') as HTMLButtonElement).click(); }); };
const q = (s: string) => document.querySelector(s) as HTMLElement | null;
const secBtn = (id: string) => q(`[data-testid=ss-section-${id}] button`) as HTMLButtonElement;
beforeEach(() => {
__resetProfileWizardForTests(); __resetCommandsForTests();
try { window.localStorage.removeItem('sitesmith.selfReview'); } catch { /* */ }
net = {
starts: [], cancels: [], deleted: [], uploads: 0,
polls: [done(replaceHeading('Fresh copy'))],
caps: VISION_ON, history: { ok: true, messages: [], active_job: null },
entitlement: { ok: true, profile_supported: true, summary: { enabled: true, status: 'OK_MONTHLY', monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } },
assets: [
{ filename: 'a.jpg', relUrl: 'assets/a.jpg', url: '/api/a', width: 800, height: 600, caption: 'A red door', alt: null },
{ filename: 'b.png', relUrl: 'assets/b.png', url: '/api/b', width: 100, height: 100, caption: null, alt: null },
],
refs: [{ id: 5, url_label: 'nice.example', what_i_like: 'Clean menu', screenshot_ids: [] }],
startFail: false, uploadFail: false,
};
vi.mocked(captureHtml).mockReset();
installFetch();
});
afterEach(() => { h?.unmount(); vi.unstubAllGlobals(); });
describe('Sitesmith modal: job states', () => {
test('shows live phases, then applies the result; the request carries the design and asset choices', async () => {
net.polls = [
{ ok: true, status: 'running', phase: 'captioning', retry_after_ms: 400 },
{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 400 },
done(replaceHeading('Fresh copy', '#334155')),
];
await mount();
await sendText('Make it nicer');
expect(q('[data-testid=ss-job-status]')).not.toBeNull();
expect(ta()).toBeNull(); // input is replaced while a job runs
await flush(1100);
expect(q('[data-testid=ss-phase-label]')!.textContent).toContain('Looking at your images');
await flush(400);
expect(q('[data-testid=ss-phase-label]')!.textContent).toContain('Designing your page');
await flush(600);
expect(q('[data-testid=ss-job-status]')).toBeNull();
expect(h.container.textContent).toContain('Fresh copy');
expect(net.starts).toHaveLength(1);
expect(net.starts[0]).toMatchObject({ site_id: 7, message: 'Make it nicer', design: { primaryColor: '#3b82f6', bodyFont: 'Inter, sans-serif' } });
expect(net.starts[0].asset_filenames).toBeUndefined(); // all assets included
// contrast repair: #334155 on #1e3a8a is repaired and reported
expect(q('[data-testid=ss-adjust]')!.textContent).toMatch(/text colour adjusted for readability/);
}, 20000);
test('cancel: asks the server, shows a note, leaves the canvas alone', async () => {
net.polls = [{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 50 }];
await mount();
await sendText('x');
await flush(50);
await h.act(async () => { q('[data-testid=ss-cancel]')!.click(); });
await flush(1300);
expect(net.cancels).toEqual([{ job_id: 'j1' }]);
expect(q('[data-testid=ss-note]')!.textContent).toMatch(/Cancelled/);
expect(h.container.textContent).toContain('Original heading');
expect(ta()).not.toBeNull();
}, 20000);
test('a job already running when the modal opens is resumed and its result applied', async () => {
net.history = { ok: true, messages: [], active_job: { job_id: 'jx', status: 'running', phase: 'generating', elapsed_s: 30, created_at: 1 } };
net.polls = [{ ok: true, status: 'running', phase: 'generating', retry_after_ms: 50 }, done(replaceHeading('Resumed result'))];
await mount();
expect(q('[data-testid=ss-job-status]')).not.toBeNull();
expect(q('[data-testid=ss-job-status]')!.textContent).toContain('Picked up a request that was already running');
expect(net.starts).toHaveLength(0);
await flush(1400);
expect(h.container.textContent).toContain('Resumed result');
expect(q('[data-testid=ss-job-status]')).toBeNull();
}, 20000);
test('a network failure gives a specific message, never the raw browser text', async () => {
net.startFail = true;
await mount();
await sendText('x');
await flush(50);
const t = q('[data-testid=ss-error]')!.textContent!;
expect(t).toMatch(/Could not reach the server/);
expect(document.body.textContent).not.toMatch(/NetworkError|Failed to fetch/);
});
test('when Sitesmith is not available the box is off and says why', async () => {
net.entitlement = { ok: true, summary: { enabled: true, status: 'CAP_REACHED', monthly_used: 10, monthly_cap: 10, bonus_credits: 0 } };
await mount();
expect(ta()!.disabled).toBe(true);
expect(q('[data-testid=ss-disabled-note]')!.textContent).toMatch(/not available on your plan/);
});
});
describe('Sitesmith modal: attachments, sites, assets, vision', () => {
test('vision unavailable: explained in the mockup section, review toggle off, text generation works', async () => {
net.caps = VISION_OFF;
await mount();
await h.act(async () => { secBtn('mockups').click(); });
expect(q('[data-testid=ss-vision-note]')!.textContent).toContain('no vision-capable model');
expect((q('[data-testid=ss-mockup-input]') as HTMLInputElement).disabled).toBe(true);
expect((q('[data-testid=ss-selfreview-toggle] input') as HTMLInputElement).disabled).toBe(true);
await sendText('Hello there');
await flush(1300);
expect(net.starts[0].mockup_ids).toBeUndefined();
expect(h.container.textContent).toContain('Fresh copy');
expect(vi.mocked(captureHtml)).not.toHaveBeenCalled();
}, 20000);
test('selected "Sites I like" and excluded assets ride with the request', async () => {
await mount();
await h.act(async () => { secBtn('sites').click(); });
await h.act(async () => { (q('[data-testid=ss-ref-item] input[type=checkbox]') as HTMLInputElement).click(); });
await h.act(async () => { secBtn('assets').click(); });
await h.act(async () => { (document.querySelectorAll('[data-testid=ss-asset-item] input')[1] as HTMLInputElement).click(); });
await sendText('Use my stuff');
await flush(50);
expect(net.starts[0].reference_ids).toEqual([5]);
expect(net.starts[0].asset_filenames).toEqual(['a.jpg']);
expect(net.starts[0].use_assets).toBe(true);
expect(q('[data-testid=ss-sites-copy]')!.textContent).toMatch(/does not open, visit or fetch it/);
expect(document.querySelector('[data-testid=ss-assets]')!.textContent).toContain('A red door');
}, 20000);
test('all assets unticked sends use_assets false', async () => {
await mount();
await h.act(async () => { secBtn('assets').click(); });
await h.act(async () => { [...document.querySelectorAll('[data-testid=ss-assets] button')].find((b) => b.textContent === 'Include none')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
await sendText('x');
await flush(50);
expect(net.starts[0].use_assets).toBe(false);
}, 20000);
});
describe('Sitesmith modal: self-review', () => {
const okCapture = () => vi.mocked(captureHtml).mockResolvedValue({ ok: true, blob: new Blob([new Uint8Array(3000)], { type: 'image/jpeg' }), width: 1280, height: 900, cropped: false });
test('round trip: first result applied, ONE review job started with the render, fixes applied, render deleted, no third job', async () => {
okCapture();
net.polls = [done(replaceHeading('Built page'))];
await mount();
await sendText('Build my home page');
// Between the two jobs the poll answers the review job.
await flush(1250);
net.polls = [done(replaceHeading('Reviewed fix'))];
await flush(2600);
expect(vi.mocked(captureHtml)).toHaveBeenCalledTimes(1);
expect(net.uploads).toBe(1);
expect(net.starts).toHaveLength(2);
expect(net.starts[1].message).toMatch(/^Review the attached screenshot/);
expect(net.starts[1].mockup_ids).toEqual(['f'.repeat(32)]);
expect(net.starts[1].use_assets).toBe(false);
expect(net.deleted).toEqual(['f'.repeat(32)]);
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/looked at the finished page/);
}, 30000);
test('capture failure: non-blocking note, first result stays applied, no second job', async () => {
vi.mocked(captureHtml).mockResolvedValue({ ok: false, reason: 'capture timed out' });
await mount();
await sendText('x');
await flush(1500);
expect(h.container.textContent).toContain('Fresh copy');
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/Self-review skipped: capture timed out/);
expect(net.starts).toHaveLength(1);
expect(net.uploads).toBe(0);
}, 20000);
test('toggle off: no capture at all, and the choice is remembered', async () => {
await mount();
await h.act(async () => { (q('[data-testid=ss-selfreview-toggle] input') as HTMLInputElement).click(); });
expect(window.localStorage.getItem('sitesmith.selfReview')).toBe('off');
await sendText('x');
await flush(1500);
expect(vi.mocked(captureHtml)).not.toHaveBeenCalled();
expect(net.starts).toHaveLength(1);
}, 20000);
test('upload failure of the render: skipped note, page untouched by the review', async () => {
okCapture(); net.uploadFail = true;
await mount();
await sendText('x');
await flush(1700);
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/Self-review skipped/);
expect(net.starts).toHaveLength(1);
}, 20000);
});