diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx new file mode 100644 index 0000000..10355cc --- /dev/null +++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx @@ -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(); + 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; +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) => ( + + {tree} + + ), + }); + h.mountChild(<> {}} 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); +});