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:
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);
|
||||
});
|
||||
Reference in new issue
Block a user