Skip to content

Commit afc6cf9

Browse files
committed
Finish refactor of actions to ADR pattern, improve unit coverage of actions
1 parent 2013603 commit afc6cf9

57 files changed

Lines changed: 3168 additions & 1194 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
"containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f",
3939
"debug:action": "python3 .github/test/runner.py",
4040
"dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev",
41-
"dev:env": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev",
42-
"dev:env:verbose": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev -- --verbose",
41+
"dev:local": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev",
42+
"dev:local:verbose": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev -- --verbose",
4343
"format": "npm run format:code && npm run format:style",
4444
"format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro",
4545
"format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"",

‎playwright.config.ts‎

Lines changed: 136 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,18 @@ const buildWebServerEnv = () => {
3535
return env
3636
}
3737

38+
const workersHighParallel = process.env['GITHUB_ACTIONS'] ? 1 : '75%'
39+
const testMatchHighParallel = '**/*.spec.ts'
40+
const testIgnoreHighParallel = [
41+
'03-forms/**/*.spec.ts',
42+
'05-api/**/*.spec.ts',
43+
'06-actions/**/*.spec.ts',
44+
]
45+
const testMatchSerial = ['03-forms/**/*.spec.ts']
46+
3847
export default defineConfig({
39-
/* Look for test files in the "tests" directory, relative to this configuration file. */
48+
/** Look for test files in the "tests" directory, relative to this configuration file. */
4049
testDir: './test/e2e/specs',
41-
/* Glob patterns or regular expressions that match test files. */
42-
testMatch: '**/*.spec.ts',
4350
/** Folder for test artifacts such as screenshots, videos, traces, etc. */
4451
outputDir: `.cache/playwright/output/`,
4552
/** Tracked by Git LFS */
@@ -53,17 +60,17 @@ export default defineConfig({
5360
*/
5461
timeout: 5000,
5562
},
56-
/* Run tests in files in parallel */
63+
/** Run tests in files in parallel */
5764
fullyParallel: true,
58-
/* Fail the build on CI if you accidentally left test.only in the source code. */
65+
/** Fail the build on CI if you accidentally left test.only in the source code. */
5966
forbidOnly: !!process.env['GITHUB_ACTIONS'],
60-
/* Retry on CI only */
67+
/** Retry on CI only */
6168
retries: process.env['GITHUB_ACTIONS'] ? 2 : 0,
62-
/* Opt out of parallel tests on CI. */
63-
workers: process.env['GITHUB_ACTIONS'] ? 1 : '75%',
64-
/* Only run @ready tests in CI, all tests locally */
69+
/** Opt out of parallel tests on CI. */
70+
workers: workersHighParallel,
71+
/** Only run @ready tests in CI, all tests locally */
6572
...(process.env['GITHUB_ACTIONS'] ? { grep: /@ready/ } : {}),
66-
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
73+
/** Reporter to use. See https://playwright.dev/docs/test-reporters */
6774
reporter: process.env['GITHUB_ACTIONS']
6875
? 'github'
6976
: isCIMode
@@ -76,59 +83,166 @@ export default defineConfig({
7683
['html', { outputFolder: `.cache/playwright/reports/` }],
7784
['json', { outputFile: '.cache/playwright/results.json' }],
7885
],
79-
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
86+
/** Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
8087
use: {
8188
/** Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
8289
actionTimeout: 0,
83-
/* Base URL to use in actions like `await page.goto('/')`. */
90+
/** Base URL to use in actions like `await page.goto('/')`. */
8491
baseURL: 'http://localhost:4321',
8592
ignoreHTTPSErrors: true,
8693
/** Capture screenshot after each test failure, other options are 'on' and 'off'. */
8794
screenshot: 'only-on-failure',
88-
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
95+
/** Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
8996
trace: 'on-first-retry',
9097
},
9198

92-
/* Configure projects for major browsers */
9399
projects: [
100+
/**
101+
* ===========================================================================================
102+
*
103+
* Most tests run in parallel across all major browsers
104+
*
105+
* ==========================================================================================
106+
*/
94107
{
95108
name: 'chromium',
96109
use: { ...devices['Desktop Chrome'] },
110+
testMatch: testMatchHighParallel,
111+
testIgnore: testIgnoreHighParallel,
97112
},
98113

99114
{
100115
name: 'firefox',
101116
use: { ...devices['Desktop Firefox'] },
117+
testMatch: testMatchHighParallel,
118+
testIgnore: testIgnoreHighParallel,
102119
},
103120

104121
{
105122
name: 'webkit',
106123
use: { ...devices['Desktop Safari'] },
124+
testMatch: testMatchHighParallel,
125+
testIgnore: testIgnoreHighParallel,
107126
},
108127

109-
/* Test against mobile viewports. */
128+
/** Test against mobile viewports. */
110129
{
111-
name: 'Mobile Chrome',
130+
name: 'mobile-chrome',
112131
use: { ...devices['Pixel 5'] },
132+
testMatch: testMatchHighParallel,
133+
testIgnore: testIgnoreHighParallel,
113134
},
114135
{
115-
name: 'Mobile Safari',
136+
name: 'mobile-safari',
116137
use: { ...devices['iPhone 12'] },
138+
testMatch: testMatchHighParallel,
139+
testIgnore: testIgnoreHighParallel,
117140
},
118141

119-
/* Test against branded browsers. */
142+
/** Test against branded browsers. */
120143
{
121-
name: 'Microsoft Edge',
144+
name: 'microsoft-edge',
122145
use: { ...devices['Desktop Edge'], channel: 'msedge' },
146+
testMatch: testMatchHighParallel,
147+
testIgnore: testIgnoreHighParallel,
123148
},
124149
{
125-
name: 'Google Chrome',
150+
name: 'google-chrome',
126151
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
152+
testMatch: testMatchHighParallel,
153+
testIgnore: testIgnoreHighParallel,
154+
},
155+
156+
/**
157+
* ===========================================================================================
158+
*
159+
* These projects are designed to run serially due to database contention issues.
160+
* We use SQLite in tests, which has limited support for concurrent writes.
161+
*
162+
* ===========================================================================================
163+
*/
164+
165+
{
166+
name: 'chromium-serial',
167+
use: { ...devices['Desktop Chrome'] },
168+
testMatch: testMatchSerial,
169+
workers: 1,
170+
},
171+
172+
{
173+
name: 'firefox-serial',
174+
use: { ...devices['Desktop Firefox'] },
175+
dependencies: ['chromium-serial'],
176+
testMatch: testMatchSerial,
177+
workers: 1,
178+
},
179+
180+
{
181+
name: 'webkit-serial',
182+
use: { ...devices['Desktop Safari'] },
183+
dependencies: ['firefox-serial'],
184+
testMatch: testMatchSerial,
185+
workers: 1,
186+
},
187+
188+
/** Test against mobile viewports. */
189+
{
190+
name: 'mobile-chrome-serial',
191+
use: { ...devices['Pixel 5'] },
192+
dependencies: ['webkit-serial'],
193+
testMatch: testMatchSerial,
194+
workers: 1,
195+
},
196+
{
197+
name: 'mobile-safari-serial',
198+
use: { ...devices['iPhone 12'] },
199+
dependencies: ['mobile-chrome-serial'],
200+
testMatch: testMatchSerial,
201+
workers: 1,
202+
},
203+
204+
/** Test against branded browsers. */
205+
{
206+
name: 'microsoft-edge-serial',
207+
use: { ...devices['Desktop Edge'], channel: 'msedge' },
208+
dependencies: ['mobile-safari-serial'],
209+
testMatch: testMatchSerial,
210+
workers: 1,
211+
},
212+
{
213+
name: 'google-chrome-serial',
214+
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
215+
dependencies: ['microsoft-edge-serial'],
216+
testMatch: testMatchSerial,
217+
workers: 1,
218+
},
219+
220+
/**
221+
* ===========================================================================================
222+
*
223+
* API and Actions tests are only using the browser to fetch so no need to run
224+
* across browsers. They run serially to avoid SQLite write contention issues.
225+
*
226+
* ===========================================================================================
227+
*/
228+
229+
{
230+
name: 'api-serial',
231+
use: { ...devices['Desktop Chrome'] },
232+
testMatch: '05-api/**/*.spec.ts',
233+
workers: 1,
234+
},
235+
236+
{
237+
name: 'actions-serial',
238+
use: { ...devices['Desktop Chrome'] },
239+
testMatch: '06-actions/**/*.spec.ts',
240+
workers: 1,
127241
},
128242
],
129243

130-
/* Run your local dev server before starting the tests */
131-
/* In debug mode, assume server is already running */
244+
/** Run your local dev server before starting the tests */
245+
/** In debug mode, assume server is already running */
132246
...(isCIMode
133247
? {}
134248
: {
@@ -144,10 +258,4 @@ export default defineConfig({
144258

145259
globalSetup: './test/e2e/config/global-setup.ts',
146260
globalTeardown: './test/e2e/config/global-teardown.ts',
147-
148-
// path to the global teardown files.
149-
//globalTeardown: require.resolve('./global-teardown'),
150-
151-
// Each test is given 30 seconds.
152-
//timeout: 30000,
153261
})

‎src/actions/contact/@types/index.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,10 @@ export type EmailData = {
3232
subject: string
3333
html: string
3434
}
35+
36+
interface RequiredStringOptions {
37+
required_error: string
38+
invalid_type_error: string
39+
min: { value: number; message: string }
40+
max: { value: number; message: string }
41+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { JSDOM } from 'jsdom'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import {
5+
generateEmailContent,
6+
getFormDataFromInput,
7+
parseAttachmentsFromInput,
8+
} from '../responder'
9+
10+
describe('contact responder', () => {
11+
describe('generateEmailContent', () => {
12+
it('generates valid HTML with required elements', () => {
13+
const html = generateEmailContent(
14+
{
15+
name: 'Jane Doe',
16+
email: 'jane@example.com',
17+
service: 'Website redesign',
18+
timeline: '2-3-months',
19+
budget: '$5k-$10k',
20+
message: 'Hi team!\nWe need help <ASAP> & would like a call.',
21+
consent: true,
22+
DataSubjectId: '123e4567-e89b-12d3-a456-426614174000',
23+
},
24+
[]
25+
)
26+
27+
const dom = new JSDOM(html)
28+
const { document } = dom.window
29+
30+
expect(document.querySelector('html')).not.toBeNull()
31+
expect(document.querySelector('head')).not.toBeNull()
32+
expect(document.querySelector('body')).not.toBeNull()
33+
34+
// Content sanity checks: ensure HTML is escaped and newlines are represented.
35+
expect(document.documentElement.outerHTML).toContain('&lt;ASAP&gt;')
36+
expect(document.documentElement.outerHTML).toContain('&amp;')
37+
38+
// Header should exist and include the sender name.
39+
expect(document.querySelector('meta[charset="utf-8"]')).not.toBeNull()
40+
expect(document.querySelector('h1')?.textContent ?? '').toContain('New Contact Form Submission')
41+
expect(document.body.textContent ?? '').toContain('Jane Doe')
42+
43+
// Key fields should appear somewhere in the rendered content.
44+
expect(document.body.textContent ?? '').toContain('jane@example.com')
45+
expect(document.body.textContent ?? '').toContain('Website redesign')
46+
expect(document.body.textContent ?? '').toContain('$5k-$10k')
47+
})
48+
49+
it('renders attachments section when attachments exist', () => {
50+
const html = generateEmailContent(
51+
{
52+
name: 'Jane Doe',
53+
email: 'jane@example.com',
54+
service: 'Website redesign',
55+
timeline: 'flexible',
56+
message: 'See attached.',
57+
consent: true,
58+
},
59+
[
60+
{
61+
filename: 'brief.pdf',
62+
content: Buffer.from('pdf-bytes'),
63+
contentType: 'application/pdf',
64+
size: 1234,
65+
},
66+
]
67+
)
68+
69+
const dom = new JSDOM(html)
70+
expect(dom.window.document.body.textContent ?? '').toContain('brief.pdf')
71+
})
72+
})
73+
74+
describe('getFormDataFromInput', () => {
75+
it('maps input fields into normalized form data', () => {
76+
const input = {
77+
name: ' Jane ',
78+
email: 'jane@example.com',
79+
message: ' Hello ',
80+
service: 'Web development',
81+
timeline: '2-3-months',
82+
budget: '$5k-$10k',
83+
consent: true,
84+
DataSubjectId: '123e4567-e89b-12d3-a456-426614174000',
85+
}
86+
87+
const formData = getFormDataFromInput(input)
88+
89+
// `getFormDataFromInput` does not trim; Zod validation handles normalization.
90+
expect(formData.name).toBe(' Jane ')
91+
expect(formData.email).toBe('jane@example.com')
92+
expect(formData.message).toBe(' Hello ')
93+
expect(formData.service).toBe('Web development')
94+
expect(formData.timeline).toBe('2-3-months')
95+
expect(formData.budget).toBe('$5k-$10k')
96+
expect(formData.consent).toBe(true)
97+
expect(formData.DataSubjectId).toBe('123e4567-e89b-12d3-a456-426614174000')
98+
})
99+
})
100+
101+
describe('parseAttachmentsFromInput', () => {
102+
it('returns empty array when no attachments provided', async () => {
103+
const attachments = await parseAttachmentsFromInput({})
104+
expect(attachments).toEqual([])
105+
})
106+
107+
it('parses a File into attachment payload', async () => {
108+
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
109+
110+
const attachments = await parseAttachmentsFromInput({
111+
file0: file,
112+
})
113+
114+
expect(attachments).toHaveLength(1)
115+
116+
const [firstAttachment] = attachments
117+
if (!firstAttachment) {
118+
throw new Error('Expected first attachment to exist')
119+
}
120+
121+
expect(firstAttachment.filename).toBe('hello.txt')
122+
expect(firstAttachment.contentType).toBe('text/plain')
123+
expect(firstAttachment.size).toBeGreaterThan(0)
124+
expect(firstAttachment.content).toBeInstanceOf(Buffer)
125+
})
126+
127+
it('rejects disallowed mime types', async () => {
128+
const file = new File(['x'], 'x.exe', { type: 'application/x-msdownload' })
129+
130+
await expect(
131+
parseAttachmentsFromInput({
132+
file0: file,
133+
})
134+
).rejects.toThrow(/type/i)
135+
})
136+
})
137+
})

0 commit comments

Comments
 (0)