import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { EditorConfigProvider } from '../state/EditorConfigContext'; import { SaveGuardProvider } from '../state/SaveGuardContext'; import { ProjectExtrasProvider, useProjectExtras } from '../state/ProjectExtrasContext'; import { useSitesmith } from './useSitesmith'; 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' }; let api: ReturnType; let extras: ReturnType; let root: Root; let container: HTMLDivElement; let sends: any[]; let entitlement: any; // Per-test override: return a response-ish object to take over, or undefined to fall through. let override: ((url: string, init?: any) => any) | undefined; let calls: Array<{ url: string; init?: any }>; const Probe: React.FC = () => { api = useSitesmith(7); extras = useProjectExtras(); return null; }; beforeEach(async () => { sends = []; override = undefined; calls = []; entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' } }; vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { const json = (v: unknown) => ({ status: 200, json: async () => v }); calls.push({ url, init }); if (override) { const o = await override(url, init); if (o !== undefined) return o; } // Legacy backend: no `start` action, so existing tests exercise the blocking `send` path. if (url.includes('action=start')) return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) }; if (url.includes('action=send')) { sends.push(JSON.parse(init.body)); return json({ ok: true, response: { type: 'ask', question: 'q' } }); } if (url.includes('action=entitlement')) return json(entitlement); return json({ ok: true, messages: [] }); })); container = document.createElement('div'); document.body.appendChild(container); await act(async () => { root = createRoot(container); root.render( React.createElement(EditorConfigProvider as any, { config: CFG }, React.createElement(SaveGuardProvider as any, null, React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), ); }); }); afterEach(() => { vi.useRealTimers(); act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); describe('useSitesmith send body', () => { test('omits profile and intent when none is stored or given', async () => { await act(async () => { await api.send('hello', 'canvas'); }); expect(sends).toHaveLength(1); expect(sends[0]).not.toHaveProperty('profile'); expect(sends[0]).not.toHaveProperty('intent'); expect(sends[0].message).toBe('hello'); }); test('includes the stored profile and the intent', async () => { act(() => extras.set('sitesmithProfile', PROFILE)); await act(async () => { await api.send('regen', 'canvas', undefined, { intent: 'regenerate_section' }); }); expect(sends[0].profile).toEqual(PROFILE); expect(sends[0].intent).toBe('regenerate_section'); }); test('an incomplete stored profile is not sent', async () => { act(() => extras.set('sitesmithProfile', { ...PROFILE, services: [] })); await act(async () => { await api.send('hello', 'canvas'); }); expect(sends[0]).not.toHaveProperty('profile'); }); test('old backend (no profile_supported flag): the business line rides in the message', async () => { act(() => extras.set('sitesmithProfile', PROFILE)); await act(async () => { await api.send('hello', 'canvas'); }); expect(sends[0].message).toContain('Acme is a Plumber'); expect(sends[0].message).toContain('hello'); }); test('backend that reports profile_supported keeps the message untouched', async () => { entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' }, profile_supported: true }; await act(async () => { await api.refreshEntitlement(); }); act(() => extras.set('sitesmithProfile', PROFILE)); await act(async () => { await api.send('hello', 'canvas'); }); expect(sends[0].message).toBe('hello'); expect(sends[0].profile).toEqual(PROFILE); }); }); // --------------------------------------------------------------------------- // start + poll transport // --------------------------------------------------------------------------- type R = { status?: number; body?: any; raw?: 'nonjson' | 'throw' }; const resp = (r: R) => { if (r.raw === 'throw') throw new TypeError('Failed to fetch'); return { status: r.status ?? 200, json: async () => { if (r.raw === 'nonjson') throw new SyntaxError('Unexpected token <'); return r.body; } }; }; const JOB = 'a'.repeat(32); let starts: any[]; let polls: number; let history: any; let historyFetches: number; /** Scripted backend: start -> startRes, poll -> pollScript (last entry repeats). */ function script(startRes: R, pollScript: R[]) { starts = []; polls = 0; historyFetches = 0; history = { ok: true, messages: [] }; override = (url, init) => { if (url.includes('action=start')) { starts.push(JSON.parse(init.body)); return resp(startRes); } if (url.includes('action=poll')) { const r = pollScript[Math.min(polls, pollScript.length - 1)]; polls++; return resp(r); } if (url.includes('action=history')) { historyFetches++; return resp({ body: history }); } return undefined; }; } const running = (phase = 'generating', extra: any = {}): R => ({ body: { ok: true, job_id: JOB, status: 'running', phase, elapsed_s: 1, ...extra } }); const done = (result: any, status = 'done'): R => ({ body: { ok: true, job_id: JOB, status, phase: 'done', elapsed_s: 9, result } }); const OKSTART: R = { body: { ok: true, job_id: JOB, status: 'running', phase: 'queued', created_at: 1 } }; const DONE_OK = done({ ok: true, response: { type: 'ask', question: 'q' } }); /** Start a send, drive fake timers for `ms`, and return its result. */ async function sendFor(ms: number, ...args: any[]): Promise { vi.useFakeTimers(); let p!: Promise; await act(async () => { p = (api.send as any)(...args); await vi.advanceTimersByTimeAsync(ms); }); return await p; } describe('useSitesmith start+poll', () => { test('happy path: start then poll to done, reports phases, refreshes history', async () => { script(OKSTART, [running('captioning'), running('generating'), running('generating'), DONE_OK]); const phases: string[] = []; const before = calls.filter((c) => c.url.includes('action=history')).length; const res = await sendFor(30000, 'hi', 'canvas', undefined, { mockupIds: ['m1'], referenceIds: [3], useAssets: false, onProgress: (p: string) => phases.push(p) }); expect(res).toEqual({ ok: true, response: { type: 'ask', question: 'q' } }); expect(phases).toEqual(['queued', 'captioning', 'generating', 'done']); expect(starts[0]).toMatchObject({ message: 'hi', mockup_ids: ['m1'], reference_ids: [3], use_assets: false }); expect(calls.filter((c) => c.url.includes('action=history')).length).toBeGreaterThan(before); expect(calls.some((c) => c.url.includes('action=send'))).toBe(false); }); test('polls back off from ~1s towards a 5s cap', async () => { script(OKSTART, [running()]); vi.useFakeTimers(); let p!: Promise; await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(900); }); expect(polls).toBe(0); await act(async () => { await vi.advanceTimersByTimeAsync(200); }); expect(polls).toBe(1); await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); // 60s at <=5s spacing -> well under the 60 a fixed 1s interval would give expect(polls).toBeLessThan(25); expect(polls).toBeGreaterThan(10); await act(async () => { await vi.advanceTimersByTimeAsync(400000); }); await p; }); test('retry_after_ms from the server is honoured', async () => { script(OKSTART, [running('generating', { retry_after_ms: 4000 }), DONE_OK]); vi.useFakeTimers(); let p!: Promise; await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(1100); }); expect(polls).toBe(1); await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); expect(polls).toBe(1); await act(async () => { await vi.advanceTimersByTimeAsync(1100); }); expect(polls).toBe(2); expect((await p).ok).toBe(true); }); test('job error result maps to {ok:false,status,message}', async () => { script(OKSTART, [done({ ok: false, status: 'AI_ERROR', message: 'The model failed.' }, 'error')]); const res = await sendFor(3000, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'AI_ERROR', message: 'The model failed.' }); }); test('cancelled job resolves CANCELLED', async () => { script(OKSTART, [done({ ok: false, status: 'CANCELLED', message: 'Cancelled.' }, 'cancelled')]); const res = await sendFor(3000, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'CANCELLED' }); }); test('entitlement refusal from start (HTTP 200) passes through', async () => { script({ body: { ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' } }, [DONE_OK]); const res = await sendFor(10, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' }); expect(polls).toBe(0); }); test('network error on start gives a friendly message and does not throw', async () => { script({ raw: 'throw' }, [DONE_OK]); const res = await sendFor(10, 'hi', 'c'); expect(res).toEqual({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' }); }); test('transient poll failures are retried and recovered', async () => { script(OKSTART, [{ raw: 'throw' }, { status: 502, raw: 'nonjson' }, { raw: 'throw' }, DONE_OK]); const res = await sendFor(60000, 'hi', 'c'); expect(res.ok).toBe(true); expect(polls).toBe(4); }); test('poll failures exhausted -> LOST_CONNECTION mentioning reopening', async () => { script(OKSTART, [{ raw: 'throw' }]); const res = await sendFor(120000, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'LOST_CONNECTION', jobId: JOB }); expect(res.message).toMatch(/Lost connection/); expect(res.message).toMatch(/reopen Sitesmith/); }); test('overall 6 minute cap -> TIMEOUT (job may still finish)', async () => { script(OKSTART, [running()]); const res = await sendFor(7 * 60 * 1000, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'TIMEOUT', jobId: JOB }); expect(res.message).toMatch(/background/); expect(res.message).toMatch(/history/); }); test('abort via signal stops polling and resolves ABORTED', async () => { script(OKSTART, [running()]); const ac = new AbortController(); vi.useFakeTimers(); let p!: Promise; await act(async () => { p = api.send('hi', 'c', undefined, { signal: ac.signal }); await vi.advanceTimersByTimeAsync(3000); }); const seen = polls; expect(seen).toBeGreaterThan(0); ac.abort(); await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); const res = await p; expect(res).toMatchObject({ ok: false, status: 'ABORTED' }); expect(polls).toBe(seen); }); test('409 job_in_progress resolves JOB_IN_PROGRESS with jobId and exposes activeJob; resumeJob finishes it', async () => { script({ status: 409, body: { ok: false, error: 'job_in_progress', status: 'JOB_IN_PROGRESS', message: 'Already working.', job_id: JOB } }, [DONE_OK]); const res = await sendFor(10, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'JOB_IN_PROGRESS', message: 'Already working.', jobId: JOB }); expect(api.activeJob?.job_id).toBe(JOB); const h0 = historyFetches; vi.useFakeTimers(); let p!: Promise; await act(async () => { p = api.resumeJob(); await vi.advanceTimersByTimeAsync(2000); }); expect((await p).ok).toBe(true); expect(historyFetches).toBeGreaterThan(h0); expect(api.activeJob).toBeNull(); }); test('history.active_job populates activeJob on mount', async () => { override = (url) => url.includes('action=history') ? resp({ body: { ok: true, messages: [], active_job: { job_id: JOB, status: 'running', phase: 'generating', elapsed_s: 3 } } }) : undefined; act(() => root.unmount()); root = createRoot(container); await act(async () => { root.render( React.createElement(EditorConfigProvider as any, { config: CFG }, React.createElement(SaveGuardProvider as any, null, React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), ); }); expect(api.activeJob).toMatchObject({ job_id: JOB, phase: 'generating' }); }); test('start unknown_action falls back to legacy send once and is remembered', async () => { let legacy = 0; starts = []; polls = 0; override = (url, init) => { if (url.includes('action=start')) { starts.push(1); return resp({ status: 400, body: { ok: false, error: 'unknown_action' } }); } if (url.includes('action=send')) { legacy++; return resp({ body: { ok: true, response: { type: 'ask', question: 'old' } } }); } return undefined; }; const r1 = await sendFor(10, 'hi', 'c'); expect(r1).toEqual({ ok: true, response: { type: 'ask', question: 'old' } }); const r2 = await sendFor(10, 'again', 'c'); expect(r2.ok).toBe(true); expect(legacy).toBe(2); expect(starts).toHaveLength(1); }); test('legacy send fetch rejection becomes a friendly result', async () => { override = (url) => { if (url.includes('action=start')) return resp({ status: 400, body: { ok: false, error: 'unknown_action' } }); if (url.includes('action=send')) return resp({ raw: 'throw' }); return undefined; }; const res = await sendFor(10, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' }); }); test.each([ [403, 'nonjson', /HTTP 403/, 'FORBIDDEN'], [413, 'nonjson', /HTTP 413/, 'TOO_LARGE'], [429, 'nonjson', /HTTP 429/, 'RATE_LIMITED'], [503, 'nonjson', /HTTP 503/, 'SERVER_ERROR'], [200, 'nonjson', /could not read/, 'BAD_RESPONSE'], ])('start HTTP %s with non-JSON body maps to a distinct message', async (status, raw, re, code) => { script({ status: status as number, raw: raw as any }, [DONE_OK]); const res = await sendFor(10, 'hi', 'c'); expect(res.ok).toBe(false); expect(res.status).toBe(code); expect(res.message).toMatch(re as RegExp); }); test('session expiry (401 / csrf) tells the user to refresh and sign in', async () => { script({ status: 403, body: { ok: false, error: 'csrf', message: 'Bad token' } }, [DONE_OK]); let res = await sendFor(10, 'hi', 'c'); expect(res.status).toBe('AUTH_REQUIRED'); expect(res.message).toMatch(/Refresh the page and sign in/); script({ status: 401, raw: 'nonjson' }, [DONE_OK]); res = await sendFor(10, 'hi', 'c'); expect(res.status).toBe('AUTH_REQUIRED'); }); test('a sensible server message wins over the generic one', async () => { script({ status: 400, body: { ok: false, error: 'message_too_long', message: 'Message is too long (max 4000 characters).' } }, [DONE_OK]); const res = await sendFor(10, 'hi', 'c'); expect(res.message).toBe('Message is too long (max 4000 characters).'); }); test('unmount stops polling and does not throw', async () => { script(OKSTART, [running()]); vi.useFakeTimers(); let p!: Promise; await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(3000); }); const seen = polls; expect(seen).toBeGreaterThan(0); act(() => root.unmount()); root = createRoot(container); // so afterEach can unmount cleanly await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); expect((await p).status).toBe('ABORTED'); expect(polls).toBe(seen); }); test('cancel posts job_id with CSRF header', async () => { script(OKSTART, [DONE_OK]); override = (url, init) => url.includes('action=cancel') ? resp({ body: { ok: true, status: 'cancelled' } }) : undefined; const r = await api.cancel(JOB); expect(r.ok).toBe(true); const c = calls.find((x) => x.url.includes('action=cancel'))!; expect(c.init.headers['X-CSRF-Token']).toBe('t'); expect(JSON.parse(c.init.body)).toEqual({ job_id: JOB }); }); }); describe('useSitesmith capabilities and wrappers', () => { test('capabilities tolerated missing (old backend 400)', async () => { override = (url) => url.includes('action=capabilities') ? resp({ status: 400, body: { ok: false, error: 'unknown_action' } }) : undefined; await act(async () => { await api.refreshEntitlement(); }); expect(api.capabilities).toBeNull(); expect(api.summary).not.toBeNull(); }); test('capabilities populated when the backend provides them', async () => { const caps = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: false }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } }; override = (url) => url.includes('action=capabilities') ? resp({ body: caps }) : undefined; // capabilities are fetched once; remount to fetch with the override in place act(() => root.unmount()); root = createRoot(container); await act(async () => { root.render( React.createElement(EditorConfigProvider as any, { config: CFG }, React.createElement(SaveGuardProvider as any, null, React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), ); }); expect(api.capabilities?.features.mockups).toBe(true); }); test('wrappers send CSRF on POSTs and never throw on network failure', async () => { override = (url) => resp({ body: { ok: true, references: [], images: [], reference: { id: 1 }, assets: [] } }); expect((await api.listRefs()).ok).toBe(true); expect((await api.listAssets()).ok).toBe(true); await api.addReference({ url_label: 'x', what_i_like: 'y' }); await api.updateReference(1, { what_i_like: 'z' }); await api.deleteReference(1); await api.deleteRefImage('img1'); const posts = calls.filter((c) => c.init?.method === 'POST' && /refs_|ref_image_delete/.test(c.url)); expect(posts).toHaveLength(4); for (const p of posts) { expect(p.init.headers['X-CSRF-Token']).toBe('t'); expect(JSON.parse(p.init.body).site_id).toBe(7); } override = () => resp({ raw: 'throw' }); expect(await api.listRefs()).toMatchObject({ ok: false, message: expect.stringMatching(/Could not reach/) }); expect(await api.deleteReference(1)).toMatchObject({ ok: false }); expect(api.refImageUrl('a b')).toBe('/api/sitesmith.php?action=ref_image&site_id=7&id=a%20b'); }); test('multipart upload sends CSRF but no manual Content-Type', async () => { override = (url) => url.includes('action=ref_image_upload') ? resp({ body: { ok: true, image: { id: 'i1', kind: 'mockup', name: 'a.png', width: 1, height: 1, size: 3 } } }) : undefined; const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' }); const r = await api.uploadRefImage(file, 'mockup'); expect(r.ok).toBe(true); const c = calls.find((x) => x.url.includes('ref_image_upload'))!; expect(c.init.method).toBe('POST'); expect(c.init.body).toBeInstanceOf(FormData); expect(c.init.body.get('kind')).toBe('mockup'); expect(c.init.body.get('site_id')).toBe('7'); expect(c.init.headers['X-CSRF-Token']).toBe('t'); expect(Object.keys(c.init.headers).map((k) => k.toLowerCase())).not.toContain('content-type'); override = () => resp({ status: 413, raw: 'nonjson' }); expect(await api.uploadRefImage(file, 'mockup')).toMatchObject({ ok: false, message: expect.stringMatching(/413/) }); }); }); describe('request payload: design, assets and attachments', () => { test('start carries design, mockup ids, reference ids, use_assets and asset_filenames exactly', async () => { const bodies: any[] = []; override = (url, init) => { if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j9', status: 'running', phase: 'queued' }) }; } if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response: { type: 'ask', question: 'q' } } }) }; return undefined; }; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); try { let p!: Promise; await act(async () => { p = api.send('hi', 'canvas', undefined, { design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] }, mockupIds: ['a'.repeat(32)], referenceIds: [3, 4], useAssets: true, assetFilenames: ['x.png'], }); await vi.advanceTimersByTimeAsync(1200); await p; }); } finally { vi.useRealTimers(); } expect(bodies).toHaveLength(1); expect(bodies[0]).toMatchObject({ site_id: 7, message: 'hi', canvas_summary: 'canvas', design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] }, mockup_ids: ['a'.repeat(32)], reference_ids: [3, 4], use_assets: true, asset_filenames: ['x.png'], }); }); test('an empty design is not sent', async () => { const bodies: any[] = []; override = (url, init) => { if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) }; } return undefined; }; await act(async () => { await api.send('hi', 'c', undefined, { design: {} }); }); expect(sends[0].design).toBeUndefined(); }); });