diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f73d178 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,138 @@ +name: E2E Tests + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'e2e/**' + - '.github/workflows/e2e.yml' + pull_request: + branches: [main] + paths: + - 'src/**' + - 'e2e/**' + - '.github/workflows/e2e.yml' + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + # ── Node / frontend ──────────────────────────────────────────────────── + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install frontend dependencies + run: | + npm ci + # Platform-specific native packages required on Linux + npm install --force \ + @rollup/rollup-linux-x64-gnu@4.34.9 \ + @swc/core-linux-x64-gnu + + # ── Playwright ──────────────────────────────────────────────────────── + - name: Install Playwright dependencies + run: npm ci + working-directory: e2e + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: e2e + + # ── Backend binary ──────────────────────────────────────────────────── + - name: Get latest donetick release tag + id: release + run: | + TAG=$(curl -fsSL https://api.github.com/repos/donetick/donetick/releases/latest \ + | grep '"tag_name"' | head -1 \ + | sed 's/.*"tag_name": "\(.*\)".*/\1/') + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Using backend tag: $TAG" + + - name: Cache donetick binary + id: cache-binary + uses: actions/cache@v4 + with: + path: .donetick-bin/ + key: donetick-${{ steps.release.outputs.tag }}-linux-x86_64 + + - name: Download and extract donetick binary + if: steps.cache-binary.outputs.cache-hit != 'true' + run: | + mkdir -p .donetick-bin + curl -fsSL \ + "https://github.com/donetick/donetick/releases/download/${{ steps.release.outputs.tag }}/donetick_Linux_x86_64.tar.gz" \ + -o .donetick-bin/donetick_Linux_x86_64.tar.gz + tar -xzf .donetick-bin/donetick_Linux_x86_64.tar.gz -C .donetick-bin + chmod +x .donetick-bin/donetick + + - name: Write backend config + run: | + mkdir -p .donetick-bin/config + cat > .donetick-bin/config/selfhosted.yaml << 'EOF' + name: "selfhosted" + is_done_tick_dot_com: false + is_user_creation_disabled: false + database: + type: "sqlite" + migration: true + jwt: + secret: "e2e_test_secret_32chars_long_ok!" + session_time: 168h + max_refresh: 1440h + server: + port: 2021 + read_timeout: 10s + write_timeout: 10s + rate_period: 60s + rate_limit: 300 + cors_allow_origins: + - "http://localhost:5173" + - "http://localhost:4173" + serve_frontend: false + logging: + level: "warn" + encoding: "json" + development: false + EOF + + - name: Start donetick backend + working-directory: .donetick-bin + env: + DT_ENV: selfhosted + DT_SQLITE_PATH: /tmp/donetick-e2e.db + run: ./donetick & + + # ── Frontend ────────────────────────────────────────────────────────── + # Build with --mode development so .env.development is loaded, + # which sets VITE_APP_API_URL=http://localhost:2021 + - name: Build frontend + run: npx vite build --mode development + + - name: Serve frontend + run: npx vite preview --port 5173 & + + # ── Tests ───────────────────────────────────────────────────────────── + - name: Run Playwright tests + working-directory: e2e + env: + E2E_FRONTEND_URL: http://localhost:5173 + E2E_API_URL: http://localhost:2021 + run: npx playwright test + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report/ + e2e/test-results/ + retention-days: 7 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..86849c4 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.auth/ +playwright-report/ +test-results/ diff --git a/e2e/fixtures/auth.js b/e2e/fixtures/auth.js new file mode 100644 index 0000000..13df6f6 --- /dev/null +++ b/e2e/fixtures/auth.js @@ -0,0 +1,49 @@ +import { test as base, expect } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +/** + * Fill and submit the signup form through the UI. + * After successful signup the app auto-logs in and redirects to /chores. + */ +export async function signUpViaUI(page, { username, email, password, displayName }) { + await page.goto('/signup') + await page.locator('#username').fill(username) + await page.locator('#email').fill(email) + await page.locator('#password').fill(password) + await page.locator('#displayName').fill(displayName) + await page.getByRole('button', { name: 'Sign Up' }).click() + await page.waitForURL('**/chores', { timeout: 10_000 }) +} + +/** + * Fill and submit the login form through the UI. + * After successful login the app redirects to /chores. + */ +export async function loginViaUI(page, { username, password }) { + await page.goto('/login') + // The username input on the login page has id="email" (quirk of the form) + await page.locator('#email').fill(username) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await page.waitForURL('**/chores', { timeout: 10_000 }) +} + +/** + * A Playwright test fixture that provides a page already authenticated as the + * shared E2E user (via persisted storage state, no UI interaction required). + */ +export const test = base.extend({ + authenticatedPage: async ({ browser }, use) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '..', '.auth', 'state.json'), + }) + const page = await ctx.newPage() + await use(page) + await ctx.close() + }, +}) + +export { expect } diff --git a/e2e/global-setup.js b/e2e/global-setup.js new file mode 100644 index 0000000..1d949d9 --- /dev/null +++ b/e2e/global-setup.js @@ -0,0 +1,96 @@ +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// FRONTEND_URL: what the browser navigates to (Playwright baseURL) +// API_URL: where the Go API lives (may be the same in production builds) +export const FRONTEND_URL = + process.env.E2E_FRONTEND_URL || + process.env.E2E_BASE_URL || + 'http://localhost:5173' +export const API_URL = + process.env.E2E_API_URL || process.env.E2E_BASE_URL || 'http://localhost:2021' + +export const TEST_USER = { + username: 'e2e.user', + email: 'e2e@donetick.test', + password: 'E2ePassword123!', + displayName: 'E2E User', +} + +/** + * Global setup: create the shared E2E test user (idempotent) and persist + * the JWT token as a Playwright storage state so authenticated tests can + * skip the login UI. + */ +export default async function globalSetup() { + await waitForServer(API_URL) + + // Create user – ignore 409/conflict so re-runs are safe + const signupRes = await fetch(`${API_URL}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }) + if (!signupRes.ok) { + const body = await signupRes.text() + // Treat "already exists" errors as success so re-runs are idempotent + if (!body.includes('already exists') && !body.includes('already taken')) { + throw new Error(`Signup failed (${signupRes.status}): ${body}`) + } + } + + // Login and grab the access token + const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: TEST_USER.username, + password: TEST_USER.password, + }), + }) + if (!loginRes.ok) { + const body = await loginRes.text() + throw new Error(`Login failed (${loginRes.status}): ${body}`) + } + + const { token, expire } = await loginRes.json() + + // Write a Playwright storage-state file containing the token in localStorage + const stateDir = path.join(__dirname, '.auth') + await mkdir(stateDir, { recursive: true }) + await writeFile( + path.join(stateDir, 'state.json'), + JSON.stringify({ + cookies: [], + origins: [ + { + origin: FRONTEND_URL, + localStorage: [ + { name: 'token', value: token }, + { name: 'token_expiry', value: expire }, + ], + }, + ], + }), + 'utf8', + ) + + console.log('[global-setup] Test user ready, storage state saved.') +} + +async function waitForServer(url, retries = 20, delayMs = 1000) { + for (let i = 0; i < retries; i++) { + try { + const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } }) + if (res.status < 500) return + } catch { + // server not up yet + } + console.log(`[global-setup] Waiting for server… (${i + 1}/${retries})`) + await new Promise(r => setTimeout(r, delayMs)) + } + throw new Error(`Server at ${url} did not become ready in time`) +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..703eadb --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "donetick-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "donetick-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.44.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..28d59dc --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "donetick-e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.44.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..046be39 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +// When frontend and backend are the same origin (embedded binary) set E2E_BASE_URL. +// For dev, set E2E_FRONTEND_URL (default 5173) and E2E_API_URL (default 2021) separately. +const FRONTEND_URL = + process.env.E2E_FRONTEND_URL || + process.env.E2E_BASE_URL || + 'http://localhost:5173' + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + workers: 1, // sequential – single SQLite DB + reporter: process.env.CI ? 'github' : 'list', + + use: { + baseURL: FRONTEND_URL, + headless: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + globalSetup: './global-setup.js', + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js new file mode 100644 index 0000000..bf657dd --- /dev/null +++ b/e2e/tests/auth.spec.js @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test' +import { signUpViaUI, loginViaUI } from '../fixtures/auth.js' + +// Username must match /^[a-z.-]+$/ — no digits allowed. +// Generate a random lowercase-only suffix for uniqueness across runs. +function randomSuffix(len = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz' + return Array.from({ length: len }, () => + chars[Math.floor(Math.random() * 26)], + ).join('') +} + +test.describe('Auth – Sign Up', () => { + test('creates a new account and lands on /chores', async ({ page }) => { + const suffix = randomSuffix() + const user = { + username: `test.signup.${suffix}`, + email: `signup.${suffix}@donetick.test`, + password: 'TestPassword123!', + displayName: 'Test Signup User', + } + + await signUpViaUI(page, user) + + await expect(page).toHaveURL(/\/chores/) + }) + + test('shows an error when username is too short', async ({ page }) => { + await page.goto('/signup') + await page.locator('#username').fill('ab') // < 4 chars + await page.locator('#email').fill('short@donetick.test') + await page.locator('#password').fill('ValidPass123!') + await page.locator('#displayName').fill('Short User') + await page.getByRole('button', { name: 'Sign Up' }).click() + + await expect( + page.getByText('Username must be at least 4 characters'), + ).toBeVisible() + }) +}) + +test.describe('Auth – Login', () => { + // Re-use the shared E2E user that global-setup already created + test('logs in with valid credentials and lands on /chores', async ({ page }) => { + await loginViaUI(page, { + username: 'e2e.user', + password: 'E2ePassword123!', + }) + + await expect(page).toHaveURL(/\/chores/) + }) + + test('shows an error for wrong password', async ({ page }) => { + await page.goto('/login') + await page.locator('#email').fill('e2e.user') + await page.locator('#password').fill('WrongPassword!') + await page.getByRole('button', { name: 'Sign In' }).click() + + // The notification snackbar / error message should appear + await expect( + page.getByText(/Login Failed|invalid|incorrect/i).first(), + ).toBeVisible({ timeout: 5_000 }) + }) +}) diff --git a/e2e/tests/chores.spec.js b/e2e/tests/chores.spec.js new file mode 100644 index 0000000..c0c65ec --- /dev/null +++ b/e2e/tests/chores.spec.js @@ -0,0 +1,53 @@ +import { expect, test } from '../fixtures/auth.js' +import { API_URL } from '../global-setup.js' + +test.describe('Chores – Create', () => { + // All tests in this suite run as the pre-authenticated E2E user + test.use({ + storageState: '.auth/state.json', + }) + + test('creates a daily recurring chore and confirms it appears in the list', async ({ + page, + }) => { + const choreName = `E2E Daily Chore ${Date.now()}` + + // ── Navigate to the create chore page ──────────────────────────────── + await page.goto('/chores/create') + + // ── Fill in the chore name ──────────────────────────────────────────── + // Name input is the first on the page (no id/placeholder) + await page.locator('input').first().fill(choreName) + + // ── Enable recurrence ───────────────────────────────────────────────── + // Check the "Repeat this task" checkbox to reveal frequency options + await page.getByLabel('Repeat this task').click() + + // Select "Daily" from the frequency chips + await page.getByLabel('Daily').click() + + // The due date is auto-populated to today once a repeating type is selected. + // No manual date entry required unless testing date-specific behaviour. + + // ── Save ────────────────────────────────────────────────────────────── + await page.getByRole('button', { name: 'Create' }).click() + + // After save the app navigates back to the chore list + await page.waitForURL('**/chores', { timeout: 15_000 }) + + // ── Verify the chore appears in the UI ─────────────────────────────── + await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 }) + + // ── Verify the chore exists in the API ─────────────────────────────── + const token = await page.evaluate(() => localStorage.getItem('token')) + const apiRes = await fetch(`${API_URL}/api/v1/chores/`, { + headers: { Authorization: `Bearer ${token}` }, + }) + expect(apiRes.ok).toBe(true) + + const { res: chores } = await apiRes.json() + const created = chores.find(c => c.name === choreName) + expect(created).toBeDefined() + expect(created.frequencyType).toBe('daily') + }) +})