Sitesmith: tests for payload, design, attachments, panels, self-review runner

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:40:55 -08:00
1 parent 79159dcb28
commit 784d85346e
5 files changed
+385 -2

No files matched your search

+39
View File
@@ -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<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();
});
});
@@ -62,14 +62,15 @@ export const SitesILikePanel: React.FC<Props> = ({
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); }
};
@@ -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<string[]>(initial);
return (
<AttachmentTray images={images} selected={sel} onSelectedChange={setSel} maxAttach={max} referenceImageCount={0} imageUrl={(id) => `/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(<Harness />);
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(<Harness onUpload={up} />);
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(<Harness onUpload={async () => ({ 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(<Harness initial={['a']} />);
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(<Harness max={1} initial={['a']} />);
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(<Harness reason="Image features are not available on this server." />);
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 = '<img src=x onerror=alert(1)>.png';
await render(<AttachmentTray images={[{ ...img('z'), name: evil }]} selected={[]} onSelectedChange={() => {}} 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<number[]>([]);
return (
<SitesILikePanel references={refs} images={[img('s1', 'screenshot')]} selected={sel} onSelectedChange={setSel} imageUrl={(i) => `/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(<Harness api={{}} />);
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(<Harness api={{}} />);
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(<Harness api={{ add }} />);
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(<Harness api={{ add }} />);
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(<Harness api={{ update, del }} />);
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(<Harness api={{}} reason="Image features are not available here." />);
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: '<img src=x onerror=alert(1)>', what_i_like: '<b>bold</b>', screenshot_ids: [] };
await render(<SitesILikePanel references={[evil]} images={[]} selected={[]} onSelectedChange={() => {}} 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('<img src=x onerror=alert(1)>');
});
});
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(<AssetAwareness assets={assets} loaded included={new Set(['a.jpg'])} onToggle={onToggle} onAll={onAll} thumbUrl={(a) => 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(<AssetAwareness assets={[]} loaded included={new Set()} onToggle={() => {}} 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(<AssetAwareness assets={[]} loaded error="Could not reach the server." included={new Set()} onToggle={() => {}} 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(<JobStatus phase="captioning" onCancel={cancel} />);
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(<JobStatus phase="generating" cancelling onCancel={cancel} />);
expect(box.textContent).toContain('Cancelling');
expect((box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).disabled).toBe(true);
});
});
@@ -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> = {}): SelfReviewDeps => ({
composeHtml: () => '<html><body>x</body></html>',
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: () => '<img src="assets/a.png">', resolveAssetUrls: resolve, assetUrls: { 'assets/a.png': '/api/a' } });
await runSelfReview(d);
expect((d.capture as any).mock.calls[0][0]).toContain('/api/a');
});
});
+60
View File
@@ -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<b>', 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([...'<svg xmlns="x">'].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<b>.com', 'x'.repeat(301)]) {
expect(normalizeUrlLabel(bad).ok, bad).toBe(false);
}
});
});