Skip to content
Draft
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
21 changes: 19 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ jobs:
run: npm i
- run: npm run build

# Blocking: fast + reliable. See TESTING.md.
test:
name: 🔬 Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm i
- run: npm run test:run

lint:
runs-on: ubuntu-latest
steps:
Expand All @@ -34,5 +49,7 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm i
# Lint currently reporting lots of preexisting issues
# - run: npm run lint
# Advisory (warn, non-blocking) until the preexisting issues are burned down.
# Ratchet to blocking once clean. See TESTING.md ("block vs. warn").
- run: npm run lint
continue-on-error: true
61 changes: 61 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: E2E

# Heavy job (spins up both servers + browsers), so run only on PRs to develop.
on:
pull_request:
branches: [ develop ]

jobs:
e2e:
name: 🎭 Playwright
runs-on: ubuntu-latest
steps:
# Frontend (this repo) into ./ and backend into ./backend so the app can
# run end-to-end. Pins the backend to its develop branch; bump if needed.
- name: Checkout frontend
uses: actions/checkout@v4

- name: Checkout backend
uses: actions/checkout@v4
with:
repository: donetick/donetick
ref: develop
path: backend

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: backend/go.sum

- name: Install frontend dependencies
run: npm ci

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

- name: Prebuild backend
working-directory: backend
run: go build -o /dev/null .

- name: Run E2E tests
env:
BE_DIR: backend
# Backend requires a >=32-char, non-weak JWT secret. This is a
# throwaway secret for the ephemeral CI database only.
DT_JWT_SECRET: ci-e2e-throwaway-secret-000000000000
run: npm run test:e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
39 changes: 39 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Lighthouse

# Advisory only: PWA / accessibility / performance signal on the public pages.
# Never blocks a merge (assertions are warnings); the report is uploaded as an
# artifact. See be/TESTING.md ("warn only"). PR-to-develop only to avoid noise.
on:
pull_request:
branches: [ develop ]

jobs:
lighthouse:
name: 🔦 Lighthouse (advisory)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build frontend
run: npm run build

- name: Run Lighthouse CI
run: npx lhci autorun
continue-on-error: true

- name: Upload Lighthouse report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: lighthouse-report
path: lighthouse-report/
retention-days: 7
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,12 @@ dist-ssr
*.sw?

resources/android/**/*
resources/ios/**/*
resources/ios/**/*

# Test artifacts
coverage
test-results
playwright-report
.playwright
lighthouse-report
.lighthouseci
52 changes: 52 additions & 0 deletions e2e/a11y.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { signUpNewUser } from './helpers'

// Automated accessibility scans of the critical-flow pages using axe-core.
//
// Automation catches only ~30-57% of WCAG issues (see be/TESTING.md), so this
// is a floor, not proof of conformance — manual keyboard + screen-reader passes
// still matter. We gate on the two highest-impact levels (critical + serious).
//
// The current MUI Joy UI has known critical/serious violations that the coming
// shadcn rebuild will rework. Rather than block on that pre-existing debt (or
// disable the check entirely), each page has a KNOWN-issues baseline: the test
// fails only on a NEW critical/serious violation type — real regression
// protection today. As shadcn fixes each issue, delete it from the baseline;
// when a baseline reaches [] the page is fully gated.
const GATE = ['critical', 'serious']

// axe rule IDs currently failing at critical/serious. SHRINK these as they're
// fixed — never grow them without a deliberate decision.
const KNOWN = {
signup: ['color-contrast', 'label'],
taskList: ['aria-progressbar-name', 'button-name', 'color-contrast'],
}

// a11y scanning runs on the desktop project only; mobile contrast/layout is a
// separate concern and would add noise here.
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'a11y scan runs on chromium only')
})

async function newGatingViolations(page, known) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze()
return results.violations
.filter(v => GATE.includes(v.impact))
.filter(v => !known.includes(v.id))
.map(v => ({ id: v.id, impact: v.impact }))
}

test('signup page has no NEW critical/serious a11y violations', async ({ page }) => {
await page.goto('/signup')
const regressions = await newGatingViolations(page, KNOWN.signup)
expect(regressions, JSON.stringify(regressions, null, 2)).toEqual([])
})

test('task list has no NEW critical/serious a11y violations', async ({ page }) => {
await signUpNewUser(page)
const regressions = await newGatingViolations(page, KNOWN.taskList)
expect(regressions, JSON.stringify(regressions, null, 2)).toEqual([])
})
10 changes: 10 additions & 0 deletions e2e/auth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect, test } from '@playwright/test'
import { signUpNewUser } from './helpers'

// Critical flow #1: a new user can sign up and lands authenticated.
test('a new user can sign up and reach the authenticated app', async ({ page }) => {
await signUpNewUser(page)

// We should be on an authenticated route, not back at login/signup.
await expect(page).not.toHaveURL(/\/(login|signup)/)
})
43 changes: 43 additions & 0 deletions e2e/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect } from '@playwright/test'

// Encode a number as lowercase letters (a-z). The username field only allows
// lowercase letters, dots and dashes, so we can't use digits.
function toLetters(n) {
let s = ''
do {
s = String.fromCharCode(97 + (n % 26)) + s
n = Math.floor(n / 26)
} while (n > 0)
return s
}

// Creates a unique account so tests are independent and can re-run against a
// persistent dev database without username collisions.
export function uniqueUser() {
// Username regex is strict: /^[a-z.-]+$/ (lowercase letters, dot, dash only).
const id = toLetters(Date.now()) + toLetters(Math.floor(Math.random() * 1e6))
return {
username: `etest-${id}`,
email: `${id}@example.com`,
password: 'e2e-password-123',
displayName: `E2E test ${id}`,
}
}

// Signs a fresh user up through the real UI and waits until the app has
// auto-logged-in and navigated away from the signup page.
export async function signUpNewUser(page) {
const user = uniqueUser()

await page.goto('/signup')
await page.locator('input[name="username"]').fill(user.username)
await page.locator('input[name="email"]').fill(user.email)
await page.locator('input[name="password"]').fill(user.password)
await page.locator('input[name="displayName"]').fill(user.displayName)
await page.getByRole('button', { name: /sign up/i }).click()

// On success the app auto-logs-in and redirects off /signup.
await expect(page).not.toHaveURL(/\/signup/, { timeout: 15000 })

return user
}
26 changes: 26 additions & 0 deletions e2e/tasks.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test'
import { signUpNewUser } from './helpers'

// Critical flows #2 and #3: a user can create a task and complete it.
test('a user can create a task and mark it done', async ({ page }) => {
await signUpNewUser(page)

const taskName = `Buy milk ${Date.now()}`

// Create — the name field is the first textbox on the create form.
await page.goto('/chores/create')
await page.getByRole('textbox').first().fill(taskName)
await page.getByRole('button', { name: 'Create' }).click()

// The new task should appear in the list.
await expect(page.getByText(taskName).first()).toBeVisible({ timeout: 15000 })

// Open the task detail and complete it via the labelled action button.
await page.getByText(taskName).first().click()
await page.getByRole('button', { name: /mark as done/i }).click()

// The detail page reflects the completion in its statistics, and the
// "mark as done" action is no longer offered for the finished task.
await expect(page.getByText(/completed:\s*1 times/i)).toBeVisible({ timeout: 15000 })
await expect(page.getByRole('button', { name: /mark as done/i })).toHaveCount(0)
})
27 changes: 27 additions & 0 deletions lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run preview",
"url": [
"http://localhost:4173/login",
"http://localhost:4173/signup"
],
"numberOfRuns": 1,
"settings": {
"preset": "desktop"
}
},
"assert": {
"assertions": {
"categories:accessibility": ["warn", { "minScore": 0.9 }],
"categories:performance": ["warn", { "minScore": 0.5 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:seo": ["warn", { "minScore": 0.9 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": "./lighthouse-report"
}
}
}
Loading