diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts index daf0645..07d4196 100644 --- a/craft/src/hooks/useSitesmith.test.ts +++ b/craft/src/hooks/useSitesmith.test.ts @@ -401,3 +401,42 @@ describe('useSitesmith capabilities and wrappers', () => { 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(); + }); +}); diff --git a/craft/src/panels/sitesmith/SitesILikePanel.tsx b/craft/src/panels/sitesmith/SitesILikePanel.tsx index 06ffcfb..7337910 100644 --- a/craft/src/panels/sitesmith/SitesILikePanel.tsx +++ b/craft/src/panels/sitesmith/SitesILikePanel.tsx @@ -62,14 +62,15 @@ export const SitesILikePanel: React.FC = ({ const addShot = async (files: FileList | null) => { if (!files || !form) return; setErr(null); setBusy(true); + let count = form.shots.length; try { for (const f of Array.from(files)) { - if (form.shots.length >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; } + if (count >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; } const r = await uploadShot(f); if (!r.ok) { setErr(r.message); break; } const id = r.image!.id; + count++; setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s)); - form.shots = [...form.shots, id]; } } finally { setBusy(false); } }; diff --git a/craft/src/panels/sitesmith/SitesmithPanels.test.tsx b/craft/src/panels/sitesmith/SitesmithPanels.test.tsx new file mode 100644 index 0000000..edb523e --- /dev/null +++ b/craft/src/panels/sitesmith/SitesmithPanels.test.tsx @@ -0,0 +1,214 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React, { useState } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { AttachmentTray } from './AttachmentTray'; +import { SitesILikePanel } from './SitesILikePanel'; +import { AssetAwareness } from './AssetAwareness'; +import { JobStatus } from './JobStatus'; + +let root: Root; let box: HTMLDivElement; +beforeEach(() => { box = document.createElement('div'); document.body.appendChild(box); root = createRoot(box); }); +afterEach(() => { act(() => root.unmount()); box.remove(); }); +const render = (el: React.ReactElement) => act(async () => { root.render(el); }); +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const png = (name = 'm.png') => new File([PNG], name, { type: 'image/png' }); +const setFiles = (input: HTMLInputElement, files: File[]) => { + Object.defineProperty(input, 'files', { value: files, configurable: true }); + input.dispatchEvent(new Event('change', { bubbles: true })); +}; +const img = (id: string, kind: 'mockup' | 'screenshot' = 'mockup') => ({ id, kind, name: `${id}.png`, width: 100, height: 80, size: 2048 }); + +describe('AttachmentTray', () => { + const Harness: React.FC<{ onUpload?: any; reason?: string | null; initial?: string[]; max?: number }> = ({ onUpload, reason, initial = [], max = 4 }) => { + const [images, setImages] = useState([img('a'), img('b')]); + const [sel, setSel] = useState(initial); + return ( + `/img/${id}`} + onUpload={onUpload ?? (async (f: File) => { const i = img('new'); setImages((x) => [...x, i]); return { ok: true as const, image: i }; })} + onRemove={async (id: string) => { setImages((x) => x.filter((i) => i.id !== id)); return { ok: true as const }; }} + unavailableReason={reason} /> + ); + }; + test('picker upload adds a thumbnail and selects it', async () => { + await render(); + setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png()]); + await flush(); + const items = box.querySelectorAll('[data-testid=ss-mockup-item]'); + expect(items).toHaveLength(3); + expect(items[2].querySelector('img')!.getAttribute('src')).toBe('/img/new'); + expect((items[2].querySelector('input[type=checkbox]') as HTMLInputElement).checked).toBe(true); + }); + test('drop uploads too; thumbnails and names are text/attributes only', async () => { + const up = vi.fn(async () => ({ ok: true as const, image: img('d') })); + await render(); + const zone = box.querySelector('[data-testid=ss-dropzone]')!; + const ev = new Event('drop', { bubbles: true, cancelable: true }) as any; + ev.dataTransfer = { files: [png('drop.png')] }; + await act(async () => { zone.dispatchEvent(ev); }); + await flush(); + expect(up).toHaveBeenCalledTimes(1); + }); + test('a rejected upload shows its message in an alert and adds nothing', async () => { + await render( ({ ok: false as const, message: 'That file is not a JPEG, PNG, WebP or GIF image.' })} />); + setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png()]); + await flush(); + expect(box.querySelector('[role=alert]')!.textContent).toContain('not a JPEG'); + expect(box.querySelectorAll('[data-testid=ss-mockup-item]')).toHaveLength(2); + }); + test('remove deletes and deselects', async () => { + await render(); + const btn = box.querySelector('button[aria-label="Remove a.png"]') as HTMLButtonElement; + await act(async () => { btn.click(); }); + await flush(); + expect(box.querySelectorAll('[data-testid=ss-mockup-item]')).toHaveLength(1); + }); + test('limit: cannot select more than the image budget', async () => { + await render(); + const second = box.querySelectorAll('[data-testid=ss-mockup-item] input[type=checkbox]')[1] as HTMLInputElement; + await act(async () => { second.click(); }); + expect(box.querySelector('[role=alert]')!.textContent).toContain('up to 1 image'); + expect(second.checked).toBe(false); + }); + test('vision unavailable: clear note, picker and checkboxes disabled', async () => { + await render(); + expect(box.querySelector('[data-testid=ss-vision-note]')!.textContent).toContain('not available on this server'); + expect((box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement).disabled).toBe(true); + expect([...box.querySelectorAll('input[type=checkbox]')].every((c) => (c as HTMLInputElement).disabled)).toBe(true); + }); + test('a hostile image name is shown as text, never parsed as markup', async () => { + const evil = '.png'; + await render( {}} maxAttach={4} referenceImageCount={0} imageUrl={() => '/i'} onUpload={async () => ({ ok: false as const, message: '' })} onRemove={async () => ({ ok: true as const })} />); + expect(box.querySelectorAll('img').length).toBe(1); + expect(box.textContent).toContain(evil); + }); +}); + +describe('SitesILikePanel', () => { + const baseRef = { id: 1, url_label: 'example.com', what_i_like: 'Big photo header', screenshot_ids: [] as string[] }; + const Harness: React.FC<{ api: any; reason?: string | null }> = ({ api, reason }) => { + const [refs, setRefs] = useState([baseRef]); + const [sel, setSel] = useState([]); + return ( + `/img/${i}`} maxRefs={20} + addRef={async (u, l, s) => { const r = await api.add(u, l, s); if (r.ok) setRefs((x) => [...x, { id: 2, url_label: u, what_i_like: l, screenshot_ids: s }]); return r; }} + updateRef={async (id, u, l, s) => { const r = await api.update(id, u, l, s); if (r.ok) setRefs((x) => x.map((e) => (e.id === id ? { ...e, url_label: u, what_i_like: l, screenshot_ids: s } : e))); return r; }} + deleteRef={async (id) => { const r = await api.del(id); if (r.ok) setRefs((x) => x.filter((e) => e.id !== id)); return r; }} + uploadShot={async () => ({ ok: true as const, image: img('s2', 'screenshot') })} removeImage={async () => ({ ok: true as const })} + imagesUnavailableReason={reason} /> + ); + }; + const ok = { ok: true as const }; + const type = (el: HTMLInputElement | HTMLTextAreaElement, v: string) => act(async () => { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, v); + el.dispatchEvent(new Event('input', { bubbles: true })); + }); + const btn = (text: string) => [...box.querySelectorAll('button')].find((b) => b.textContent === text) as HTMLButtonElement; + + test('copy says the address is only a label and is not fetched', async () => { + await render(); + const t = box.querySelector('[data-testid=ss-sites-copy]')!.textContent!; + expect(t).toMatch(/only a label/); + expect(t).toMatch(/does not open, visit or fetch it/); + }); + test('lists, selects for the next request', async () => { + await render(); + expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(1); + const cb = box.querySelector('[data-testid=ss-ref-item] input[type=checkbox]') as HTMLInputElement; + await act(async () => { cb.click(); }); + expect(cb.checked).toBe(true); + }); + test('add: calls the API with label, notes and screenshots; the entry appears', async () => { + const add = vi.fn(async () => ok); + await render(); + await act(async () => { btn('Add a site I like').click(); }); + await type(box.querySelector('#ss-ref-url') as HTMLInputElement, 'https://nice.example'); + await type(box.querySelector('#ss-ref-likes') as HTMLTextAreaElement, 'Clean menu'); + setFiles(box.querySelector('[data-testid=ss-shot-input]') as HTMLInputElement, [png()]); + await flush(); + await act(async () => { btn('Add site').click(); }); + await flush(); + expect(add).toHaveBeenCalledWith('https://nice.example', 'Clean menu', ['s2']); + expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(2); + }); + test('add with missing notes or bad address shows the validation message from the caller', async () => { + const add = vi.fn(async () => ({ ok: false as const, message: 'Say what you like about this site so Sitesmith knows what to take from it.' })); + await render(); + await act(async () => { btn('Add a site I like').click(); }); + await act(async () => { btn('Add site').click(); }); + expect(box.querySelector('[role=alert]')!.textContent).toContain('Say what you like'); + }); + test('edit saves changes; delete needs a confirm click', async () => { + const update = vi.fn(async () => ok); const del = vi.fn(async () => ok); + await render(); + await act(async () => { btn('Edit').click(); }); + await type(box.querySelector('#ss-ref-likes') as HTMLTextAreaElement, 'New notes'); + await act(async () => { btn('Save changes').click(); }); + await flush(); + expect(update).toHaveBeenCalledWith(1, 'example.com', 'New notes', []); + expect(box.textContent).toContain('New notes'); + await act(async () => { btn('Delete').click(); }); + expect(del).not.toHaveBeenCalled(); + await act(async () => { btn('Confirm delete').click(); }); + await flush(); + expect(del).toHaveBeenCalledWith(1); + expect(box.querySelectorAll('[data-testid=ss-ref-item]')).toHaveLength(0); + }); + test('no vision: screenshots are replaced by a note; text entries still work', async () => { + await render(); + await act(async () => { btn('Add a site I like').click(); }); + expect(box.querySelector('[data-testid=ss-shot-input]')).toBeNull(); + expect(box.textContent).toContain('Image features are not available here.'); + expect(box.querySelector('#ss-ref-likes')).not.toBeNull(); + }); + test('user text is rendered as text nodes', async () => { + const evil = { id: 9, url_label: '', what_i_like: 'bold', screenshot_ids: [] }; + await render( {}} imageUrl={() => ''} maxRefs={20} addRef={async () => ok} updateRef={async () => ok} deleteRef={async () => ok} uploadShot={async () => ({ ok: false as const, message: '' })} removeImage={async () => ok} />); + expect(box.querySelectorAll('img, b').length).toBe(0); + expect(box.textContent).toContain(''); + }); +}); + +describe('AssetAwareness', () => { + const assets = [ + { filename: 'a.jpg', relUrl: 'assets/a.jpg', url: '/p/a', width: 800, height: 600, caption: 'A red door', alt: null }, + { filename: 'b.png', relUrl: 'assets/b.png', url: '/p/b', width: null, height: null, caption: null, alt: null }, + ]; + test('shows captions and a no-description fallback, and toggles', async () => { + const onToggle = vi.fn(); const onAll = vi.fn(); + await render( a.url} visionAvailable />); + const items = box.querySelectorAll('[data-testid=ss-asset-item]'); + expect(items[0].textContent).toContain('A red door'); + expect(items[0].textContent).toContain('800×600'); + expect(items[1].textContent).toContain('No description yet'); + expect(box.textContent).toContain('1 of 2 included'); + await act(async () => { (items[1].querySelector('input') as HTMLInputElement).click(); }); + expect(onToggle).toHaveBeenCalledWith('b.png'); + await act(async () => { [...box.querySelectorAll('button')].find((b) => b.textContent === 'Include none')!.click(); }); + expect(onAll).toHaveBeenCalledWith(false); + }); + test('empty, error and no-vision states are explained', async () => { + await render( {}} onAll={() => {}} thumbUrl={() => ''} visionAvailable={false} />); + expect(box.textContent).toContain('have not uploaded any images'); + expect(box.textContent).toContain('works from file names and sizes only'); + await render( {}} onAll={() => {}} thumbUrl={() => ''} visionAvailable />); + expect(box.querySelector('[role=alert]')!.textContent).toContain('Could not reach'); + }); +}); + +describe('JobStatus', () => { + test('shows the phase in plain words, cancel works, and says closing is safe', async () => { + const cancel = vi.fn(); + await render(); + expect(box.querySelector('[data-testid=ss-phase-label]')!.textContent).toContain('Looking at your images'); + expect(box.textContent).toContain('You can close this window'); + await act(async () => { (box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).click(); }); + expect(cancel).toHaveBeenCalled(); + await render(); + expect(box.textContent).toContain('Cancelling'); + expect((box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/craft/src/panels/sitesmith/selfReview.test.ts b/craft/src/panels/sitesmith/selfReview.test.ts new file mode 100644 index 0000000..28aa6fc --- /dev/null +++ b/craft/src/panels/sitesmith/selfReview.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect, vi } from 'vitest'; +import { runSelfReview, REVIEW_MESSAGE, type SelfReviewDeps } from './selfReview'; + +const blob = () => new Blob([new Uint8Array(3000)], { type: 'image/jpeg' }); +const deps = (o: Partial = {}): SelfReviewDeps => ({ + composeHtml: () => 'x', + assetUrls: {}, allowUrls: [], + resolveAssetUrls: (h) => h, + capture: vi.fn(async () => ({ ok: true as const, blob: blob(), width: 1280, height: 900, cropped: false })), + upload: vi.fn(async () => ({ ok: true as const, id: 'a'.repeat(32) })), + review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'm', ops: [{ op: 'delete_node' as const, node_id: 'n' }] } })), + applyResult: vi.fn(async () => ({ ok: true })), + removeImage: vi.fn(async () => ({})), + ...o, +}); + +describe('runSelfReview', () => { + test('round trip: capture -> upload -> ONE review call -> apply -> render image deleted', async () => { + const d = deps(); + const out = await runSelfReview(d); + expect(out.status).toBe('applied'); + expect(d.capture).toHaveBeenCalledTimes(1); + expect(d.upload).toHaveBeenCalledTimes(1); + expect(d.review).toHaveBeenCalledTimes(1); + expect(d.review).toHaveBeenCalledWith(REVIEW_MESSAGE, 'a'.repeat(32)); + expect(d.applyResult).toHaveBeenCalledTimes(1); + expect(d.removeImage).toHaveBeenCalledWith('a'.repeat(32)); + const file = (d.upload as any).mock.calls[0][0] as File; + expect(file.type).toBe('image/jpeg'); + }); + test('capture failure skips with a note and calls nothing else', async () => { + const d = deps({ capture: vi.fn(async () => ({ ok: false as const, reason: 'capture timed out' })) }); + const out = await runSelfReview(d); + expect(out).toMatchObject({ status: 'skipped' }); + expect(out.note).toContain('capture timed out'); + expect(d.upload).not.toHaveBeenCalled(); + expect(d.review).not.toHaveBeenCalled(); + expect(d.removeImage).not.toHaveBeenCalled(); + }); + test('upload failure, review failure and apply failure all skip non-blockingly; the render is still cleaned up', async () => { + expect((await runSelfReview(deps({ upload: vi.fn(async () => ({ ok: false as const, message: 'too big' })) }))).status).toBe('skipped'); + const d2 = deps({ review: vi.fn(async () => ({ ok: false as const, status: 'RATE_LIMITED' as const, message: 'Slow down' })) }); + const o2 = await runSelfReview(d2); + expect(o2).toMatchObject({ status: 'skipped' }); + expect(o2.note).toContain('Slow down'); + expect(d2.removeImage).toHaveBeenCalled(); + const d3 = deps({ applyResult: vi.fn(async () => ({ ok: false, message: 'cannot apply' })) }); + expect((await runSelfReview(d3)).status).toBe('skipped'); + expect(d3.removeImage).toHaveBeenCalled(); + }); + test('a thrown error anywhere is a skip, never an exception', async () => { + const out = await runSelfReview(deps({ capture: vi.fn(async () => { throw new Error('boom'); }) })); + expect(out).toMatchObject({ status: 'skipped' }); + expect(await runSelfReview(deps({ composeHtml: () => { throw new Error('x'); } }))).toMatchObject({ status: 'skipped' }); + }); + test('"looks good" results (empty patch / ask) apply nothing', async () => { + const d = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'fine', ops: [] } })) }); + expect((await runSelfReview(d)).status).toBe('looks-good'); + expect(d.applyResult).not.toHaveBeenCalled(); + const d2 = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'ask' as const, question: 'q' } })) }); + expect((await runSelfReview(d2)).status).toBe('looks-good'); + }); + test('assets are resolved before capture', async () => { + const resolve = vi.fn((h: string) => h.replace('assets/a.png', '/api/a')); + const d = deps({ composeHtml: () => '', resolveAssetUrls: resolve, assetUrls: { 'assets/a.png': '/api/a' } }); + await runSelfReview(d); + expect((d.capture as any).mock.calls[0][0]).toContain('/api/a'); + }); +}); diff --git a/craft/src/utils/sitesmith-design.test.ts b/craft/src/utils/sitesmith-design.test.ts new file mode 100644 index 0000000..db8481e --- /dev/null +++ b/craft/src/utils/sitesmith-design.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'vitest'; +import { buildDesignPayload } from './sitesmith-design'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; +import { normalizeUrlLabel, validateAttachment, sniffImageType, formatBytes } from './sitesmith-attachments'; + +describe('buildDesignPayload', () => { + test('sends the keys the backend prompt builder reads, with the live values', () => { + const p = buildDesignPayload({ ...DEFAULT_SITE_DESIGN, primaryColor: '#112233', headingFont: "'Playfair Display', serif", borderRadius: '12px' }); + expect(p).toMatchObject({ + primaryColor: '#112233', secondaryColor: '#8b5cf6', accentColor: '#10b981', backgroundColor: '#ffffff', textColor: '#1f2937', + mutedTextColor: '#6b7280', borderColor: '#e5e7eb', headingFont: 'Playfair Display, serif', bodyFont: 'Inter, sans-serif', borderRadius: '12px', buttonRadius: '8px', + }); + expect(Object.keys(p)).not.toContain('headCode'); + expect(Object.keys(p)).not.toContain('favicon'); + expect(p.brandColors).toBeUndefined(); + }); + test('brand swatches become brandColors (max 6, valid hex only, optional)', () => { + const swatches = [ + { label: 'Ink', hex: '#0F172A', brand: true }, { label: 'Not brand', hex: '#ffffff', brand: false }, + { label: 'Bad', hex: 'red', brand: true }, { label: 'A', hex: '#abc', brand: true }, + ...Array.from({ length: 8 }, (_, i) => ({ label: `S${i}`, hex: '#00000' + i, brand: true })), + ]; + const p = buildDesignPayload({ ...DEFAULT_SITE_DESIGN, swatches } as any); + const b = p.brandColors as Array<{ name?: string; hex: string }>; + expect(b).toHaveLength(6); + expect(b[0]).toEqual({ name: 'Ink', hex: '#0f172a' }); + expect(b[1].name).toBe('Ab'); + expect(b.every((x) => /^#[0-9a-f]{3,8}$/.test(x.hex))).toBe(true); + }); +}); + +describe('attachments validation', () => { + const file = (bytes: number[], name = 'a.png', size?: number) => { + const f = new File([new Uint8Array(bytes)], name); + if (size !== undefined) Object.defineProperty(f, 'size', { value: size }); + return f; + }; + const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]; + test('type is decided from the bytes, not the name', async () => { + expect(await sniffImageType(file(PNG, 'x.txt'))).toBe('image/png'); + expect(await sniffImageType(file([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]))).toBe('image/jpeg'); + expect(await sniffImageType(file([...'GIF89a'].map((c) => c.charCodeAt(0)).concat([0, 0])))).toBe('image/gif'); + expect(await sniffImageType(file([...'RIFF'].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0], [...'WEBP'].map((c) => c.charCodeAt(0)))))).toBe('image/webp'); + expect(await sniffImageType(file([...''].map((c) => c.charCodeAt(0)), 'a.png'))).toBeNull(); + }); + test('rejects wrong type, empty, and oversize with plain messages', async () => { + expect(await validateAttachment(file([60, 115, 118, 103, 62, 0, 0, 0], 'logo.png'))).toMatch(/not a JPEG, PNG, WebP or GIF/); + expect(await validateAttachment(file([], 'e.png'))).toMatch(/empty/); + expect(await validateAttachment(file(PNG, 'big.png', 9 * 1024 * 1024))).toMatch(/9\.0 MB; the limit is 8\.0 MB/); + expect(await validateAttachment(file(PNG, 'ok.png'))).toBeNull(); + expect(formatBytes(2048)).toBe('2 KB'); + }); + test('URL labels: kept as text, scheme-checked, never resolved', () => { + expect(normalizeUrlLabel(' example.com ')).toEqual({ ok: true, value: 'example.com' }); + expect(normalizeUrlLabel('https://a.example/x?y=1')).toMatchObject({ ok: true }); + for (const bad of ['', 'javascript:alert(1)', 'file:///etc/passwd', 'ftp://x', 'a.com', 'x'.repeat(301)]) { + expect(normalizeUrlLabel(bad).ok, bad).toBe(false); + } + }); +});