// @vitest-environment jsdom // // — the island inside the static contact page. It POSTs // { name, email, message } as JSON to CONTACT_ENDPOINT and reflects the // request lifecycle (idle → sending → sent | error). We mock `island` to a // passthrough (so the bare component renders without the marker wrapper), // pin CONTACT_ENDPOINT, and stub `fetch` to assert the submit payload and the // success + error UI transitions. // // The island is i18n-agnostic: it takes all its UI copy as a `labels` prop // (the page resolves it per-locale). We pass the English labels here so the // assertions read the same strings the page would supply. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, createElement, type ComponentType, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { ContactFormLabels } from './ContactForm.island' vi.mock('@voltro/web', () => ({ // Passthrough: return the component untouched so we test its own behavior, // not the island marker wrapper. island: (Component: ComponentType) => Component, })) vi.mock('../config', () => ({ CONTACT_ENDPOINT: 'https://fn.test/contact' })) const { default: ContactForm } = await import('./ContactForm.island') // English labels — the same strings the page resolves from the en catalog. const labels: ContactFormLabels = { name: 'Name', email: 'Email', message: 'Message', send: 'Send message', sending: 'Sending…', sentNotice: 'Thanks — your message is on its way. ✓', errorPrefix: 'Could not send:', networkError: 'network error', } ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root const fetchMock = vi.fn() const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render(node) }) } const setInput = (el: HTMLInputElement | HTMLTextAreaElement, value: string): void => { const proto = el instanceof window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set! act(() => { setter.call(el, value) el.dispatchEvent(new Event('input', { bubbles: true })) }) } const fill = (): void => { setInput(container.querySelector('#name') as HTMLInputElement, 'Ada') setInput(container.querySelector('#email') as HTMLInputElement, 'ada@example.com') setInput(container.querySelector('#message') as HTMLTextAreaElement, 'hello there') } const submit = async (): Promise => { const form = container.querySelector('form') as HTMLFormElement await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }) } beforeEach(() => { fetchMock.mockReset() vi.stubGlobal('fetch', fetchMock) }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' vi.unstubAllGlobals() }) describe('ContactForm — render', () => { test('renders the name/email/message fields and a submit button', () => { render(createElement(ContactForm, { labels })) expect(container.querySelector('#name')).toBeTruthy() expect(container.querySelector('#email')).toBeTruthy() expect(container.querySelector('#message')).toBeTruthy() expect((container.querySelector('button[type="submit"]') as HTMLButtonElement).textContent) .toContain('Send message') }) }) describe('ContactForm — submit', () => { test('POSTs the form values as JSON to CONTACT_ENDPOINT and shows the sent notice', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })) render(createElement(ContactForm, { labels })) fill() await submit() expect(fetchMock).toHaveBeenCalledTimes(1) const [url, init] = fetchMock.mock.calls[0]! expect(url).toBe('https://fn.test/contact') expect(init?.method).toBe('POST') expect(JSON.parse(init?.body as string)).toEqual({ name: 'Ada', email: 'ada@example.com', message: 'hello there', }) expect(container.textContent).toContain('on its way') }) test('surfaces the server error detail when the response is not ok', async () => { fetchMock.mockResolvedValue( new Response(JSON.stringify({ detail: 'rate limited' }), { status: 429 }), ) render(createElement(ContactForm, { labels })) fill() await submit() expect(container.textContent).toContain('Could not send') expect(container.textContent).toContain('rate limited') }) test('surfaces a network error when fetch rejects', async () => { fetchMock.mockRejectedValue(new Error('offline')) render(createElement(ContactForm, { labels })) fill() await submit() expect(container.textContent).toContain('Could not send') expect(container.textContent).toContain('offline') }) })