Skip to content

Commit eb20a24

Browse files
committed
Update actions to use destructured data and error object from actions call, add Zod types to contact and downloads actions
1 parent 8b10ca2 commit eb20a24

26 files changed

Lines changed: 419 additions & 160 deletions

File tree

.github/test/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ It is intended for local debugging of GitHub Actions only. It is not an end-to-e
1010
Run a single workflow by name (the name is the workflow filename without extension from `.github/workflows/*.yml`):
1111

1212
```bash
13-
npm run test:action:runner -- <workflowName>
13+
npm run debug:action -- <workflowName>
1414
```
1515

1616
Example:
1717

1818
```bash
19-
npm run test:action:runner -- cron
19+
npm run debug:action -- cron
2020
```
2121

2222
Run all workflows (mostly useful when iterating on the runner itself):
2323

2424
```bash
25-
npm run test:action:runner
25+
npm run debug:action
2626
```
2727

2828
## Events

.github/workflows/playwright.yml

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ jobs:
2727
CONVERTKIT_API_KEY: ${{ vars.CONVERTKIT_API_KEY }}
2828
CONVERTKIT_HTTP_PORT: ${{ vars.CONVERTKIT_HTTP_PORT }}
2929
CRON_SECRET: ${{ vars.CRON_SECRET }}
30-
DEV_SERVER_HOST: 127.0.0.1
30+
# Use localhost so IPv6-only listeners (e.g. ::1) are reachable under act.
31+
DEV_SERVER_HOST: localhost
3132
DEV_SERVER_PORT: ${{ vars.DEV_SERVER_PORT }}
3233
DISABLE_TELEMETRY: true
3334
FORCE_COLOR: 3
@@ -58,20 +59,86 @@ jobs:
5859

5960
- name: Start Astro dev server
6061
run: |
61-
npm run dev -- --host 0.0.0.0 --port "${DEV_SERVER_PORT}" > /tmp/astro-dev.log 2>&1 &
62+
echo "Starting Astro dev server on ${DEV_SERVER_HOST}:${DEV_SERVER_PORT}"
63+
64+
build_healthcheck_urls() {
65+
echo "http://localhost:${DEV_SERVER_PORT}/"
66+
echo "http://127.0.0.1:${DEV_SERVER_PORT}/"
67+
echo "http://[::1]:${DEV_SERVER_PORT}/"
68+
}
69+
70+
if [ "${DEBUG:-0}" = "1" ]; then
71+
echo "[debug] DEBUG=1"
72+
echo "[debug] DEV_SERVER_HOST=${DEV_SERVER_HOST:-<unset>}"
73+
echo "[debug] DEV_SERVER_PORT=${DEV_SERVER_PORT:-<unset>}"
74+
echo "[debug] node=$(node --version 2>/dev/null || true)"
75+
echo "[debug] npm=$(npm --version 2>/dev/null || true)"
76+
fi
77+
78+
nohup npm run dev -- --host 0.0.0.0 --port "${DEV_SERVER_PORT}" > /tmp/astro-dev.log 2>&1 &
6279
echo $! > /tmp/astro-dev.pid
6380
81+
sleep 1
82+
if ! ps -p "$(cat /tmp/astro-dev.pid)" > /dev/null 2>&1; then
83+
echo "❌ Astro dev server process exited immediately"
84+
echo '--- Astro dev server log ---'
85+
cat /tmp/astro-dev.log || true
86+
exit 1
87+
fi
88+
89+
if [ "${DEBUG:-0}" = "1" ]; then
90+
echo "[debug] astro pid=$(cat /tmp/astro-dev.pid)"
91+
ps -fp "$(cat /tmp/astro-dev.pid)" || true
92+
echo "[debug] listening ports (ss -ltnp)"
93+
ss -ltnp || true
94+
echo "[debug] curl check (verbose)"
95+
while read -r url; do
96+
echo "[debug] probing ${url}"
97+
curl -v "${url}" || true
98+
done < <(build_healthcheck_urls)
99+
echo "[debug] tail /tmp/astro-dev.log"
100+
tail -n 200 /tmp/astro-dev.log || true
101+
fi
102+
64103
- name: Wait for Astro dev server
65104
run: |
66-
for i in $(seq 1 60); do
67-
if curl -fsS "http://${DEV_SERVER_HOST}:${DEV_SERVER_PORT}/" > /dev/null; then
68-
echo "✅ Dev server is responding"
105+
build_healthcheck_urls() {
106+
echo "http://localhost:${DEV_SERVER_PORT}/"
107+
echo "http://127.0.0.1:${DEV_SERVER_PORT}/"
108+
echo "http://[::1]:${DEV_SERVER_PORT}/"
109+
}
110+
111+
probe_dev_server() {
112+
local url
113+
while read -r url; do
114+
if curl -fsS "${url}" > /dev/null; then
115+
echo "✅ Dev server is responding at ${url}"
116+
return 0
117+
fi
118+
done < <(build_healthcheck_urls)
119+
return 1
120+
}
121+
122+
# `npm run dev` runs `astro sync` first; allow time for it to finish.
123+
for i in $(seq 1 120); do
124+
if probe_dev_server; then
69125
exit 0
70126
fi
127+
128+
if [ "${DEBUG:-0}" = "1" ]; then
129+
echo "[debug] still waiting... attempt=${i}/120"
130+
if [ -f /tmp/astro-dev.pid ]; then
131+
echo "[debug] pid=$(cat /tmp/astro-dev.pid)"
132+
ps -fp "$(cat /tmp/astro-dev.pid)" || true
133+
fi
134+
ss -ltnp || true
135+
tail -n 50 /tmp/astro-dev.log || true
136+
fi
137+
71138
sleep 1
72139
done
73140
74-
echo "❌ Dev server did not start in time"
141+
echo "❌ Dev server did not start within 120 seconds"
75142
echo '--- Astro dev server log ---'
76143
cat /tmp/astro-dev.log || true
77144
exit 1

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"Linkify",
6060
"liradb",
6161
"LNAME",
62+
"ltnp",
6263
"mathbb",
6364
"Menlo",
6465
"mlflow",
@@ -72,6 +73,7 @@
7273
"Neue",
7374
"Niklas",
7475
"noabbr",
76+
"nohup",
7577
"Noto",
7678
"octocat",
7779
"oden",

_TODO.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,6 @@ Google Calendar, Apple Calendar, Microsoft Outlook and Teams, and generate iCal
9191

9292
See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom.
9393

94-
## Uppy file uploads from contact form
95-
96-
docs/CONTACT_FORM.md
97-
98-
Where to upload to?
99-
10094
## Set up webmentions
10195

10296
Needs to add real API key and test
@@ -106,4 +100,8 @@ Needs to add real API key and test
106100
- (Optional) Set up Bridgy for social media
107101
- Test with sample webmentions
108102

109-
python3 .github/test/runner.py cron
103+
## Uppy file uploads from contact form
104+
105+
docs/CONTACT_FORM.md
106+
107+
Where to upload to?

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"containers:down": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v",
3535
"containers:status": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps",
3636
"containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f",
37+
"debug:action": "python3 .github/test/runner.py",
3738
"dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev",
3839
"dev:env": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev",
3940
"dev:env:verbose": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev -- --verbose",
@@ -53,7 +54,6 @@
5354
"sync": "FORCE_COLOR=1 npx astro sync",
5455
"test": "npm run test:unit && npm run test:e2e",
5556
"test:coverage": "FORCE_COLOR=1 npx vitest run --coverage",
56-
"test:action:runner": "python3 .github/test/runner.py",
5757
"test:e2e": "FORCE_COLOR=1 npx playwright test",
5858
"test:unit": "npm run test:unit:actions && FORCE_COLOR=1 npx vitest run",
5959
"test:unit:actions": "FORCE_COLOR=1 python3 -m pytest --import-mode=importlib .github/actions .github/test",

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ export type ContactFormData = {
77
DataSubjectId?: string
88
service?: string
99
budget?: string
10-
timeline?: string
10+
timeline?: ContactTimeline
1111
website?: string
1212
}
1313

14+
export type ContactTimeline = 'asap' | '1-month' | '2-3-months' | '3-6-months' | '6-months-plus' | 'flexible'
15+
1416
export type FileAttachment = {
1517
filename: string
1618
content: Buffer
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { validateInput } from '../domain'
4+
5+
describe('contact domain validation', () => {
6+
it('accepts valid timeline values', () => {
7+
const errors = validateInput({
8+
name: 'Jane Doe',
9+
email: 'jane@example.com',
10+
message: 'This is a valid message with enough detail.',
11+
timeline: '2-3-months',
12+
consent: false,
13+
})
14+
15+
expect(errors).toEqual([])
16+
})
17+
18+
it('rejects invalid timeline values', () => {
19+
const errors = validateInput({
20+
name: 'Jane Doe',
21+
email: 'jane@example.com',
22+
message: 'This is a valid message with enough detail.',
23+
// @ts-expect-error testing invalid value
24+
timeline: 'tomorrow',
25+
consent: false,
26+
})
27+
28+
expect(errors).toContain('Invalid project timeline')
29+
})
30+
})

src/actions/contact/action.ts

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,70 @@
11
import { Resend } from 'resend'
22
import { v4 as uuidv4, validate as uuidValidate } from 'uuid'
33
import { ActionError, defineAction } from 'astro:actions'
4+
import { z } from 'astro/zod'
45
import { checkContactRateLimit } from '@actions/utils/rateLimit'
56
import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/utils/requestContext'
67
import { getPrivacyPolicyVersion, getResendApiKey, isProd } from '@actions/utils/environment/environmentActions'
78
import { ActionsFunctionError, throwActionError } from '@actions/utils/errors'
89
import { createConsentRecord } from '@actions/gdpr/domain/consentStore'
9-
import type { ContactFormData, FileAttachment, EmailData } from '@actions/contact/@types'
10-
import { generateEmailContent, parseAttachments, validateInput } from './domain'
11-
import { parseBoolean, readString } from './utils'
10+
import type { FileAttachment, EmailData } from '@actions/contact/@types'
11+
import {
12+
contactTimelineValues,
13+
generateEmailContent,
14+
getFormDataFromInput,
15+
parseAttachmentsFromInput,
16+
validateInput
17+
} from './domain'
18+
19+
const trimString = (value: unknown): unknown => (typeof value === 'string' ? value.trim() : value)
20+
21+
const emptyStringToUndefined = (value: unknown): unknown => {
22+
if (typeof value !== 'string') return value
23+
const trimmed = value.trim()
24+
return trimmed.length === 0 ? undefined : trimmed
25+
}
26+
27+
const optionalTrimmedString = (maxLength?: number) => {
28+
const base = z.string()
29+
const limited = typeof maxLength === 'number' ? base.max(maxLength) : base
30+
return z.preprocess(emptyStringToUndefined, limited.optional())
31+
}
32+
33+
const requiredTrimmedString = (minLength = 1, maxLength?: number) => {
34+
const base = z.preprocess(trimString, z.string().min(minLength))
35+
return typeof maxLength === 'number' ? base.pipe(z.string().max(maxLength)) : base
36+
}
37+
38+
const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File
39+
const optionalFile = () => z.custom<File>(isFile).optional()
40+
41+
const contactFormInputSchema = z
42+
.object({
43+
/** Core fields used by the action. */
44+
name: requiredTrimmedString(2, 100),
45+
email: z.preprocess(trimString, z.string().email().max(254)),
46+
message: requiredTrimmedString(10, 2000),
47+
/** Extra fields submitted by the form UI. */
48+
company: optionalTrimmedString(100),
49+
phone: optionalTrimmedString(50),
50+
'project_type': optionalTrimmedString(50),
51+
budget: z.preprocess(trimString, z.enum(['5k-10k', '10k-25k', '25k-50k', '50k+'])),
52+
timeline: z.preprocess(emptyStringToUndefined, z.enum(contactTimelineValues).optional()),
53+
/** Consent checkbox: value="true" when checked, otherwise missing. */
54+
consent: z.preprocess((value) => (value === 'true' ? true : false), z.boolean()).optional(),
55+
/** Optional hidden field supported by the action. */
56+
'DataSubjectId': z.preprocess(emptyStringToUndefined, z.string().uuid().optional()),
57+
/** Backwards-compatible optional fields (older contact forms). */
58+
service: optionalTrimmedString(100),
59+
website: optionalTrimmedString(200),
60+
/** File uploads (not implemented in the UI yet). When implemented, we expect keys like file1..file5. */
61+
file1: optionalFile(),
62+
file2: optionalFile(),
63+
file3: optionalFile(),
64+
file4: optionalFile(),
65+
file5: optionalFile(),
66+
})
67+
.passthrough()
1268

1369
async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise<void> {
1470
if (!isProd()) {
@@ -34,7 +90,8 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
3490
export const contact = {
3591
submit: defineAction({
3692
accept: 'form',
37-
handler: async (form: FormData, context): Promise<{ success: true; message: string }> => {
93+
input: contactFormInputSchema,
94+
handler: async (input, context): Promise<{ success: true; message: string }> => {
3895
const route = '/_actions/contact/submit'
3996

4097
try {
@@ -50,26 +107,8 @@ export const contact = {
50107
throw new ActionsFunctionError('Too many form submissions. Please try again later.', { status: 429 })
51108
}
52109

53-
const formData: ContactFormData = {
54-
name: readString(form, 'name'),
55-
email: readString(form, 'email'),
56-
message: readString(form, 'message'),
57-
consent: parseBoolean(form.get('consent')),
58-
}
59-
60-
const phone = readString(form, 'phone')
61-
const service = readString(form, 'service')
62-
const budget = readString(form, 'budget')
63-
const timeline = readString(form, 'timeline')
64-
const website = readString(form, 'website')
65-
66-
if (phone) formData.phone = phone
67-
if (service) formData.service = service
68-
if (budget) formData.budget = budget
69-
if (timeline) formData.timeline = timeline
70-
if (website) formData.website = website
71-
72-
const files = await parseAttachments(form)
110+
const formData = getFormDataFromInput(input)
111+
const files = await parseAttachmentsFromInput(input)
73112

74113
const validationErrors = validateInput(formData)
75114
if (validationErrors.length > 0) {

0 commit comments

Comments
 (0)