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('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` `; }) .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` `; }) .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`Action complete
`; })() )}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`Action complete
`; })() )}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`Form ready
`))}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`Form ready
`))}Action complete
`); action._serverConfig = { appDir: './app', publicDir: './public', plugins: [], responseOptions: { disableStreaming: () => true, }, }; const pageRoute = createRoute().get((c) => html`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` ` ) .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"'); }); }); });