443 lines
22 KiB
TypeScript
443 lines
22 KiB
TypeScript
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<typeof useSitesmith>;
|
|
let extras: ReturnType<typeof useProjectExtras>;
|
|
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<any> {
|
|
vi.useFakeTimers();
|
|
let p!: Promise<any>;
|
|
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<any>;
|
|
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<any>;
|
|
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<any>;
|
|
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<any>;
|
|
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<any>;
|
|
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<any>;
|
|
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();
|
|
});
|
|
});
|