Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ npm run format # Prettier write (ts, tsx, js, jsx, md, css, yaml)
npm run check # Prettier check (CI)
```

Node version is pinned to `22.4.0` (see `.nvmrc`).
Node version is pinned to `24` (see `.nvmrc`); `engines` requires `>=22.12.0`. CI runs on the `.nvmrc` version.

## Testing

Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
push:
branches: [main]
pull_request:

# Cancel superseded runs on the same ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Lint, types, tests, E2E
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Uses the .nvmrc version (Node 24 → npm 11). Node 24's npm fixes the
# optional-dependency bug (#4828) that made older npm skip Rolldown's
# platform-specific native binary on the Linux runner.
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Type-check
run: npx tsc --noEmit

- name: Unit / component / integration tests
run: npm test

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

- name: End-to-end tests
run: npm run test:e2e

deploy:
name: Deploy to Vercel (production)
needs: test
# Only deploy production from main, and only after tests pass.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- uses: actions/checkout@v4

- name: Install Vercel CLI
run: npm install -g vercel@latest

- name: Pull Vercel project settings & env
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

- name: Build (production)
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

- name: Deploy prebuilt output to production
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.4.0
24
19 changes: 14 additions & 5 deletions e2e/navigation-contact.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,18 @@ test.describe('Contact form', () => {
// to the pending/"Sending…" state. The true success path is covered by the
// unit test for submitContact (T7).
let actionFired = false;
// Hold the server-action request open until the test releases it, so the
// pending UI is observable without a timing race (a fixed delay is flaky on
// slow runners). When released we abort — the action never resolves into
// success and no email is ever sent.
let releaseAction!: () => void;
const held = new Promise<void>((resolve) => {
releaseAction = resolve;
});
await page.route('**/contact', async (route) => {
if (isServerActionPost(route.request())) {
actionFired = true;
// Hold the request open briefly so the pending UI is observable, then
// abort — the action result never resolves into success, and no email
// is ever sent.
await new Promise((r) => setTimeout(r, 1500));
await held;
await route.abort();
return;
}
Expand All @@ -149,9 +154,13 @@ test.describe('Contact form', () => {
const submit = page.getByRole('button', { name: 'Send message' });
await submit.click();

// Pending state: button shows the spinner copy and is disabled.
// Pending state: button shows the spinner copy and is disabled. The
// request stays held throughout, so this window can't close mid-assertion.
await expect(page.getByText('Sending…')).toBeVisible();
await expect(page.getByRole('button', { name: /Sending/ })).toBeDisabled();
expect(actionFired).toBe(true);

// Release the held request (aborts it — still no email sent).
releaseAction();
});
});
Loading
Loading