import { test, expect, describe } from 'bun:test'; import { createAction } from './actions'; import { createRoute } from './server'; import { html, render, placeholder, type HSHtml } from '@hyperspan/html'; import { createContext } from './server'; import * as z from 'zod'; describe('createAction', () => { test('creates an action with a simple form and no schema', async () => { const action = createAction({ name: 'test', schema: z.object({ name: z.string().min(1, 'Name is required'), }), }) .form((c) => { return html`
`; }) .post(async (c, { data }) => { return c.res.html(`

Hello, ${data?.name}!

`); }); expect(action).toBeDefined(); expect(action._kind).toBe('hsAction'); expect(action._path()).toContain('/__actions/'); // Test render method const request = new Request('http://localhost:3000/'); const context = createContext(request); const rendered = render(action.render(context) as HSHtml); expect(rendered).not.toBeNull(); const htmlString = rendered; expect(htmlString).toContain(' { const action = createAction({ name: 'test', }) .form((c) => { return html`
`; }) .post(async (c, { data }) => { return c.res.html(`

Hello, ${data?.name}!

`); }); // Build form data const formData = new FormData(); formData.append('name', 'John Doe'); // Test render method const request = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(request); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); const responseText = await response.text(); expect(responseText).toContain('

Hello, John Doe!

'); }); test('returns HSHtml directly from POST handler', async () => { const action = createAction({ name: 'test', }) .form((c) => { return html`
`; }) .post(async (c, { data }) => { return html`

Hello, ${data?.name}!

`; }); const formData = new FormData(); formData.append('name', 'Jane Doe'); const request = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(request); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); const responseText = await response.text(); expect(responseText).toContain('

Hello, Jane Doe!

'); }); test('errors thrown on POST handler provided by user are caught and rendered', async () => { const action = createAction({ name: 'test', }) .form((c, { error }) => { if (error) { return html`

Error: ${error.message}

`; } return html`
`; }) .post(async (c, { data }) => { throw new Error('Test error'); }); // Build form data const formData = new FormData(); formData.append('name', 'John Doe'); // Test render method const request = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(request); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(500); const responseText = await response.text(); expect(responseText).toContain('

Error: Test error

'); }); test('creates an action with a Zod schema matching form inputs', async () => { const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.email('Invalid email address'), }); const action = createAction({ name: 'test', schema, }) .form((c, { data }) => { return html`
`; }) .post(async (c, { data }) => { return c.res.html(`

Hello, ${data?.name}!

Your email is ${data?.email}.

`); }); expect(action).toBeDefined(); expect(action._kind).toBe('hsAction'); expect(action._path()).toContain('/__actions/'); // Test render method const request = new Request('http://localhost:3000/'); const context = createContext(request); const rendered = action.render(context); expect(rendered).not.toBeNull(); const htmlString = render(rendered as unknown as HSHtml); expect(htmlString).toContain('name="name"'); expect(htmlString).toContain('name="email"'); // Test fetch method with POST request to trigger validation const formData = new FormData(); formData.append('name', 'John Doe'); formData.append('email', 'john@example.com'); const postRequest = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(postRequest); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); const responseText = await response.text(); expect(responseText).toContain('Hello, John Doe!'); expect(responseText).toContain('Your email is john@example.com.'); }); test('re-renders form with error when schema validation fails', async () => { const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.email('Invalid email address'), }); const action = createAction({ name: 'test', schema, }) .form((c, { data, error }) => { return html`
${error ? html`
Validation failed
` : ''}
`; }) .post(async (c, { data }) => { return c.res.html(`

Hello, ${data?.name}!

Your email is ${data?.email}.

`); }); // Test fetch method with invalid data (missing name, invalid email) const formData = new FormData(); formData.append('email', 'not-an-email'); const postRequest = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(postRequest); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(400); const responseText = await response.text(); // Should re-render the form, not the post handler output expect(responseText).toContain('name="name"'); expect(responseText).toContain('name="email"'); expect(responseText).toContain('Validation failed'); // Should NOT contain the success message from post handler expect(responseText).not.toContain('Hello,'); }); test('uses custom error handler when provided', async () => { const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.email('Invalid email address'), }); const action = createAction({ name: 'test', schema, }) .form((c, { data, error }) => { return html`
${error ? html`
Validation failed
` : ''}
`; }) .post(async (c, { data }) => { return html`

Hello, ${data?.name}!

Your email is ${data?.email}.

`; }) .errorHandler(async (c, { data, error }) => { return html`

Caught error in custom error handler: ${error?.message}

Data: ${JSON.stringify(data)}

`; }); // Test fetch method with invalid data (missing name, invalid email) const formData = new FormData(); formData.append('email', 'not-an-email'); const postRequest = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', body: formData, }); const response = await action.fetch(postRequest); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(400); const responseText = await response.text(); // Should render the custom error handler expect(responseText).toContain('Invalid email address'); // Should NOT contain the success message from post handler expect(responseText).not.toContain('Hello,'); }); describe('when streaming is disabled', () => { test('action POST returns resolved placeholder content on the route', async () => { const action = createAction({ name: 'async-placeholder-action-route' }) .form(() => html`
`) .post(async () => { return html`
${placeholder( html`Saving...`, (async () => { await new Promise((resolve) => setTimeout(resolve, 10)); return html`

Action complete

`; })() )}
`; }); action._config.responseOptions = { disableStreaming: () => true, }; const request = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', headers: { Accept: 'text/html', 'X-Request-Type': 'partial', }, body: new FormData(), }); const response = await action.fetch(request); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked'); const text = await response.text(); expect(text).toContain('

Action complete

'); expect(text).not.toContain('Saving...'); expect(text).not.toContain('hs:loading'); }); test('action POST returns resolved placeholder content on the server', async () => { const action = createAction({ name: 'async-placeholder-action-server' }) .form(() => html`
`) .post(async () => { return html`
${placeholder( html`Saving...`, (async () => { await new Promise((resolve) => setTimeout(resolve, 10)); return html`

Action complete

`; })() )}
`; }); action._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; const request = new Request(`http://localhost:3000${action._path()}`, { method: 'POST', headers: { Accept: 'text/html', 'X-Request-Type': 'partial', }, body: new FormData(), }); const response = await action.fetch(request); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked'); const text = await response.text(); expect(text).toContain('

Action complete

'); expect(text).not.toContain('Saving...'); expect(text).not.toContain('hs:loading'); }); test('action GET returns resolved placeholder content on the server', async () => { const action = createAction({ name: 'async-placeholder-action-get' }) .form(() => { return html`
${placeholder(html`Loading...`, Promise.resolve(html`

Form ready

`))}
`; }) .post(async () => html`

Action complete

`); action._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; const response = await action.fetch(new Request(`http://localhost:3000${action._path()}`)); expect(response.status).toBe(200); expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked'); const text = await response.text(); expect(text).toContain('

Form ready

'); expect(text).not.toContain('Loading...'); expect(text).not.toContain('hs:loading'); }); test('embedded action.render() uses the parent page route streaming config, not the action server config', async () => { const action = createAction({ name: 'embedded-action-streaming' }) .form(() => { return html`
${placeholder(html`Loading...`, Promise.resolve(html`

Form ready

`))}
`; }) .post(async () => html`

Action complete

`); action._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; const pageRoute = createRoute().get((c) => html`
${action.render(c)}
`); let pageResponse = await pageRoute.fetch(new Request('http://localhost:3000/')); expect(pageResponse.headers.get('Transfer-Encoding')).toBe('chunked'); expect(await pageResponse.text()).toContain('Loading...'); pageRoute._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; pageResponse = await pageRoute.fetch(new Request('http://localhost:3000/')); expect(pageResponse.headers.get('Transfer-Encoding')).not.toBe('chunked'); const text = await pageResponse.text(); expect(text).toContain('

Form ready

'); expect(text).not.toContain('Loading...'); }); test('action validation error re-render returns resolved placeholder content', async () => { const schema = z.object({ name: z.string().min(1, 'Name is required') }); const action = createAction({ name: 'async-placeholder-action-error', schema }) .form( (c, { error }) => html`
${placeholder( html`Loading...`, Promise.resolve(html`

Form ready

`) )}
${error ? html`

${error.message}

` : ''}
` ) .post(async () => html`

Action complete

`); action._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; const response = await action.fetch( new Request(`http://localhost:3000${action._path()}`, { method: 'POST', headers: { Accept: 'text/html', 'X-Request-Type': 'partial', }, body: new FormData(), }) ); expect(response.status).toBe(400); expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked'); const text = await response.text(); expect(text).toContain('

Form ready

'); expect(text).not.toContain('Loading...'); expect(text).toContain('class="error"'); }); }); });