diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1471511..8ad8bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,49 @@ jobs: - name: Build run: CGO_ENABLED=0 go build ./... + e2e: + name: End-to-end + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + working-directory: web + run: npm ci --no-audit --no-fund + + # Chromium only. A smoke test exists to catch a build that does not work at + # all; running it three times in three engines finds the same breakage three + # times and costs three times as much. + - name: Install Chromium + working-directory: web + run: npx playwright install --with-deps chromium + + # The suite builds the frontend, embeds it and starts the real binary — see + # playwright.config.js. There is nothing to build here first. + - name: Test + working-directory: web + run: npm run test:e2e + + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + web/test-results/ + web/playwright-report/ + retention-days: 7 + docker: name: Docker image builds runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index fa6956d..630ad9f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,12 @@ node_modules/ /web/build/ /web/.svelte-kit/ +# End-to-end runs: the throwaway instance the suite starts, the saved session it +# signs in with, and whatever a failure left behind. +/web/.playwright-data/ +/web/e2e/.auth/ +/web/test-results/ +/web/playwright-report/ + # Built documentation /site_build/ diff --git a/web/e2e/auth.setup.js b/web/e2e/auth.setup.js new file mode 100644 index 0000000..6d3bfc8 --- /dev/null +++ b/web/e2e/auth.setup.js @@ -0,0 +1,36 @@ +import { test as setup, expect } from '@playwright/test'; + +/** + * Creates the first account and saves the session for every other test. + * + * This is also the sign-in smoke test: if the bootstrap form, the session cookie + * or the app shell is broken, nothing below runs and the failure names this file. + */ + +export const USER = { + name: 'Kristian', + email: 'test@example.dk', + password: 'et langt kodeord til test' +}; + +const AUTH_FILE = 'e2e/.auth/user.json'; + +setup('opret den første konto og log ind', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Opret den første konto')).toBeVisible(); + + // Regexes, not exact strings: the password label carries a hint beside it, so + // its accessible name is the label plus that sentence. + await page.getByLabel(/Navn/).fill(USER.name); + await page.getByLabel(/E-mail/).fill(USER.email); + await page.getByLabel(/Adgangskode/).fill(USER.password); + await page.getByRole('button', { name: 'Opret konto' }).click(); + + // The shell, not just a redirect: a 200 that renders nothing would pass a + // URL assertion and fail a person. + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible(); + + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js new file mode 100644 index 0000000..eff4d9e --- /dev/null +++ b/web/e2e/smoke.spec.js @@ -0,0 +1,170 @@ +import { test, expect } from '@playwright/test'; + +/** + * The four flows a broken build must not survive: add a task, tick it off, reach + * every page in the sidebar, and open a task. + * + * Every test asserts on *rendered* content rather than on a URL. A route that no + * longer exists still answers 200 here — the Go binary serves the SPA shell for + * anything it does not recognise — so `expect(page).toHaveURL()` would pass on + * precisely the bug this suite exists to catch. + */ + +/** + * Collects anything that went wrong quietly: a thrown exception, a console error, + * or an API call that came back a failure. + * + * Failed responses are recorded with their URL and status. "A console error + * happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned + * 500" is. + */ +function watchForTrouble(page) { + const trouble = []; + page.on('console', (message) => { + // The browser logs its own line for every failed fetch. It carries no URL, + // so it is noise next to the response listener below. + if (message.type() === 'error' && !message.text().includes('Failed to load resource')) { + trouble.push(message.text()); + } + }); + page.on('pageerror', (error) => trouble.push(String(error))); + page.on('response', (response) => { + const url = response.url(); + if (url.includes('/api/') && !response.ok()) { + trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`); + } + }); + return trouble; +} + +test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const box = page.getByLabel('Ny opgave'); + // "i dag" rather than "i morgen": this is the Today view, and a task parsed + // onto tomorrow is correctly absent from it — which would look like the quick + // add had failed. + await box.fill('køb kaffe i dag p1'); + await box.press('Enter'); + + // The server parses the line, so this asserts the whole round trip: "i dag" + // and "p1" are gone from the title because they became a date and a priority. + const task = page.getByText('køb kaffe', { exact: true }); + await expect(task).toBeVisible(); + + await page.getByRole('button', { name: 'Markér som færdig' }).first().click(); + await expect(task).toBeHidden(); + + expect(trouble).toEqual([]); +}); + +test('hvert link i sidebjælken fører et sted hen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + const links = await sidebar.getByRole('link').all(); + expect(links.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger + + for (const link of links) { + const href = await link.getAttribute('href'); + await link.click(); + + // Something with a heading rendered. A route written outside the routes + // tree lands on the SPA shell and renders nothing — which is exactly how + // the label route broke, and it answered 200 the whole time. + await expect( + page.locator('main h1, main h2').first(), + `${href} renderede ingenting` + ).toBeVisible({ timeout: 5000 }); + } + + expect(trouble).toEqual([]); +}); + +test('en opgave kan åbnes og redigeres', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const box = page.getByLabel('Ny opgave'); + // Dated, so it lands in this view: an undated task goes to the Inbox and is + // correctly absent here. + await box.fill('skriv rapport i dag'); + await box.press('Enter'); + + await page.getByText('skriv rapport', { exact: true }).click(); + + const drawer = page.getByRole('complementary', { name: 'Opgave' }); + await expect(drawer).toBeVisible(); + + // A sub-task, a comment and a description at once — which is also the shape + // that used to fail: three concurrent writes met SQLite's deferred BEGIN and + // one of them came back a 500. + await drawer.getByLabel('Beskrivelse').fill('Inden fredag.'); + await drawer.getByLabel('Ny undertask').fill('lav dispositionen'); + await drawer.getByLabel('Ny undertask').press('Enter'); + await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.'); + await drawer.getByRole('button', { name: 'Skriv' }).click(); + + await expect(drawer.getByText('lav dispositionen')).toBeVisible(); + await expect(drawer.getByText('Anders har materialet.')).toBeVisible(); + + // Nothing rolled back: a toast here means the server refused a write. + await expect(page.locator('.toast')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await expect(drawer).toBeHidden(); + + // The description reached the row behind the drawer, so it was really saved. + await expect(page.getByText('Inden fredag.')).toBeVisible(); + + expect(trouble).toEqual([]); +}); + +test('hver fane under indstillinger renderer', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/indstillinger'); + + const tabs = page.getByRole('navigation', { name: 'Indstillinger' }); + await expect(tabs).toBeVisible(); + + for (const label of [ + 'Konto', + 'Notifikationer', + 'Integrationer', + 'AI', + 'API-tokens', + 'Data og skabeloner' + ]) { + await tabs.getByRole('link', { name: label, exact: true }).click(); + await expect( + page.locator('section.panel').first(), + `fanen ${label} renderede ingen sektion` + ).toBeVisible({ timeout: 5000 }); + } + + expect(trouble).toEqual([]); +}); + +/** + * The PWA's own files. Both of the bugs found last in this project were of this + * kind — a manifest promising icons that were never generated — and they are + * invisible to every other test in the repository. + */ +test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => { + const manifestResponse = await request.get('/manifest.webmanifest'); + expect(manifestResponse.ok()).toBeTruthy(); + + const manifest = await manifestResponse.json(); + for (const icon of manifest.icons ?? []) { + const iconResponse = await request.get(icon.src); + expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy(); + } + + // The service worker is what Web Push needs; without it a subscription cannot + // be created at all. + const worker = await request.get('/sw.js'); + expect(worker.ok()).toBeTruthy(); + expect(await worker.text()).toContain("addEventListener('push'"); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 8911771..f21ea69 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "verdande-web", "version": "0.1.0", "devDependencies": { + "@playwright/test": "^1.62.1", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", @@ -477,6 +478,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1304,6 +1321,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/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/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/web/package.json b/web/package.json index 2795b3a..af3191a 100644 --- a/web/package.json +++ b/web/package.json @@ -7,9 +7,11 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./jsconfig.json" + "check": "svelte-check --tsconfig ./jsconfig.json", + "test:e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", diff --git a/web/playwright.config.js b/web/playwright.config.js new file mode 100644 index 0000000..94b4283 --- /dev/null +++ b/web/playwright.config.js @@ -0,0 +1,101 @@ +import { defineConfig, devices } from '@playwright/test'; +import { rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +/** + * End-to-end smoke tests. + * + * These drive the *whole* application — the Go binary with the frontend embedded + * in it — rather than the dev server. That is deliberate: the two bugs that + * survived longest in this project were a route written outside the routes tree + * and a manifest promising files that were never generated. Neither is visible to + * a Go test, and neither is visible to a Vite build either. Only something that + * opens the real binary and clicks the real links finds them. + * + * `go run` rather than a prebuilt binary: the build cache makes the second run + * fast, and there is no separate build step to forget. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = join(here, '..'); +const dataDir = join(here, '.playwright-data'); + +// A clean instance every run: the first test creates the first account, which only +// works on an empty database. +// +// Only in the main process. Playwright re-imports this file in every worker, and a +// wipe there deletes the database out from under the running server — which does +// not fail loudly. Connections already open keep working against the unlinked +// inode, so most requests still succeed and only the ones that need a *new* +// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug. +if (process.env.TEST_WORKER_INDEX === undefined) { + rmSync(dataDir, { recursive: true, force: true }); +} + +const PORT = 8097; +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + // One worker, and not parallel: there is one server, one database and one + // account. Parallelism here would only test whether the tests race. + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + // Pinned, because the app reads both. The sign-up form sends + // `navigator.language` as the account's locale — which decides whether + // "i morgen" or "tomorrow" parses — and every due date is resolved in the + // account's zone. On a CI runner in UTC with an en-US browser, an + // unpinned suite tests a different application than the one people use. + locale: 'da-DK', + timezoneId: 'Europe/Copenhagen' + }, + + projects: [ + { name: 'setup', testMatch: /.*\.setup\.js/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' }, + dependencies: ['setup'] + } + ], + + webServer: { + // The whole chain, every run. + // + // The frontend is embedded in the binary at compile time, from a copy under + // cmd/verdande/webbuild — so a suite that merely started the server would + // test whatever frontend was copied there last, which during development is + // nearly always the wrong one. Testing a stale build is worse than not + // testing: it reports green on code that is not running. + command: [ + 'npm run build', + 'rm -rf ../cmd/verdande/webbuild', + 'cp -r build ../cmd/verdande/webbuild', + 'cd .. && go run -tags embedweb ./cmd/verdande' + ].join(' && '), + cwd: here, + url: `${baseURL}/healthz`, + reuseExistingServer: false, + // The first run compiles the binary and builds nothing else; a cold Go + // build on a CI runner is comfortably under two minutes. + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + VERDANDE_ADDR: `:${PORT}`, + VERDANDE_BASE_URL: baseURL, + VERDANDE_DATA_DIR: dataDir, + // Off: an update check would reach out to GitHub from a test run. + VERDANDE_UPDATE_CHECK: 'false' + } + } +});