diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 0000000..59b9704 --- /dev/null +++ b/.env.e2e @@ -0,0 +1,8 @@ +# Placeholder env for the e2e build (vite --mode e2e). These values only feed +# URL construction; every outbound call is intercepted by the fake bridge +# (tests/e2e/bridge), so no real network/auth ever happens. Safe to commit. +VITE_APP_TITLE=Uren assistent (e2e) +VITE_GOOGLE_CLIENT_ID=e2e-google-client-id +VITE_GOOGLE_CLIENT_SECRET=e2e-google-client-secret +VITE_GEMINI_API_KEY=e2e-gemini-key +VITE_SIMPLICATE_BASE_URL=https://e2e.simplicate.test/api/v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79f011..b6380ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,41 @@ jobs: - name: Coverage report if: ${{ !cancelled() }} uses: davelosert/vitest-coverage-report-action@v2 + + e2e: + name: E2E (Playwright / WebKit) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # Only WebKit — it matches the Tauri webview the app ships in. The browser + # binary is cached implicitly by setup-node's npm cache miss otherwise; deps + # are the OS libraries WebKit needs on the runner. + - name: Install Playwright WebKit + system deps + run: npx playwright install --with-deps webkit + + # playwright.config.ts's webServer builds the prod bundle (vite build --mode + # e2e) and serves it (vite preview); specs drive it with the fake bridge. + - name: Run e2e tests + run: npm run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 95c7af4..52a6ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ src-tauri/target/ .superpowers/ coverage/ +# Playwright e2e +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +tests/e2e/.generated/ + # Local AI-assisted dev planning docs (not for publication) docs/superpowers/ diff --git a/docs/adr/0005-e2e-playwright-mocked-tauri-bridge.md b/docs/adr/0005-e2e-playwright-mocked-tauri-bridge.md new file mode 100644 index 0000000..027c5f8 --- /dev/null +++ b/docs/adr/0005-e2e-playwright-mocked-tauri-bridge.md @@ -0,0 +1,31 @@ +--- +status: accepted +--- + +# End-to-end tests via Playwright/WebKit against a mocked Tauri bridge + +Features kept breaking when new ones landed. Unit tests (vitest/jsdom) cover usecases and components in isolation, but nothing exercised the full app — boot, classify, edit the timeline, book, submit — the way a user does. We want a suite that walks every primary flow on every PR and fails when one breaks, **without** any real network, auth, SSO, or calls to Simplicate/Gemini/GitHub/Linear/Google Calendar. + +## Decision + +- **Drive the real built web frontend with Playwright.** Playwright loads the production bundle (`vite build && vite preview`). The store, hooks, usecases, deterministic packer, timeline, drag, and leftover sidebar all run for real — only the backend is faked. +- **Cut the seam at the Tauri bridge, two channels.** In a plain browser every `@tauri-apps/api/*` call (incl. `plugin-fs` `readTextFile`/`writeTextFile` and `appDataDir`) funnels through `window.__TAURI_INTERNALS__.invoke`; only `fetch` (GitHub, Linear, Google Calendar, Google OAuth) is separate. We intercept exactly these two: `invoke` via `page.addInitScript`, `fetch` via `page.route`. +- **Stateful in-memory fake.** The fake holds mutable state (hour entries, submissions, settings, keychain, history). `book_hours` adds an entry that a subsequent week read returns as booked (blue); `submit_week` locks the week; delete/edit mutate. Flows are tested by cause→effect, not by asserting a call was made. +- **Auth is always seeded, never driven through the UI.** SSO is never performed. A `seedAuth` helper primes the fake keychain so `useRestoreSession` lands straight on the week page. The `LoginPage` SSO flow stays covered by unit tests only. +- **Run on WebKit.** The desktop app ships a WebKit webview (WKWebView / webkit2gtk), not Chromium. Specs run on Playwright's WebKit so WebKit-specific rendering/JS breaks are caught. +- **Determinism via a frozen clock.** `page.clock` pins "now" to a fixed instant; all fixtures are dated relative to it, so week selection, the 8h fill target, and recurring-pattern cadence are stable on any CI run day. +- **Gates merge.** A new `e2e` job in `ci.yml` runs the suite on every PR (build + preview + webkit) and is a required status check. + +## Considered options + +- **WebdriverIO + tauri-driver against the compiled binary.** Rejected: "no network/auth" is hard when the real Rust commands run (you must inject fakes into Rust too), and it needs webkit2gtk + tauri-driver + a cargo build in CI — slow and flakier. The bridge-mock approach tests the entire frontend experience, which is where the regressions actually happen. +- **Vitest browser mode / vitest+jsdom full-app render.** Rejected as the e2e level: jsdom can't catch real layout/drag/scroll/CSS breaks; browser mode is newer and less battle-tested for full-flow navigation than the Playwright runner. Unit tests stay on vitest/jsdom. +- **Mock at module level via a `VITE_E2E` build that aliases the bridge packages.** Reasonable alternative; rejected in favor of Playwright-side injection so no production build path or app code changes to accommodate tests — the shipped bundle is what gets tested. +- **Chromium engine.** Rejected for fidelity: it is not the engine the desktop app runs on mac/Linux. + +## Consequences + +- New dev dependency (`@playwright/test`) and a `playwright.config.ts`; a new `test:e2e` script and an `e2e` CI job. Coverage thresholds stay on the unit job; e2e does not count toward them. +- A fake-bridge module (authored in TS, bundled to one JS injected via `addInitScript`) must track the real `invoke` command names and `fetch` URLs/response shapes. When a repository changes a command name or endpoint, the fake must change too — this is intentional: the fake is the contract the frontend depends on. +- Selectors prefer `getByRole`/`getByText` against the real Dutch copy (doubling as a copy regression check); `data-testid` is added to source only for dynamic items (a specific timeline block, leftover row, day cell). +- Making the `e2e` check *required* is a one-time GitHub branch-protection setting, outside the repo. diff --git a/package-lock.json b/package-lock.json index 400ec5d..e32b584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", @@ -1244,6 +1245,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -4443,6 +4460,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/package.json b/package.json index c748820..3396dea 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:e2e": "vite build --mode e2e", "preview": "vite preview", + "preview:e2e": "vite preview --port 4173 --strictPort", "tauri": "tauri", "test": "vitest run --passWithNoTests", "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx" @@ -28,6 +32,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9efd061 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from '@playwright/test' + +// E2E suite. See docs/adr/0005-e2e-playwright-mocked-tauri-bridge.md. +// +// We drive the REAL production bundle (vite build --mode e2e -> vite preview) on +// WebKit — the same engine family as the Tauri webview the app ships in. The +// backend (Tauri `invoke` + `fetch`) is faked entirely in the browser by the +// bridge injected in tests/e2e/support/fixtures.ts, so no network/auth/SSO runs. + +const PORT = 4173 +const BASE_URL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + globalSetup: './tests/e2e/global-setup.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI + ? [['html', { open: 'never' }], ['github'], ['list']] + : [['html', { open: 'never' }], ['list']], + + use: { + baseURL: BASE_URL, + // Determinism: pin timezone + locale so date/week math is identical on any + // CI runner. The clock itself is frozen per-test in the `app` fixture. + timezoneId: 'Europe/Amsterdam', + locale: 'nl-NL', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + webServer: { + command: 'npm run build:e2e && npm run preview:e2e', + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, +}) diff --git a/src/store/appStore.ts b/src/store/appStore.ts index 3715d99..c5d9f4d 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -97,3 +97,12 @@ export const useAppStore = create()((set) => ({ setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), })) + +// E2E only: expose the store so the Playwright harness can seed an authenticated +// session without driving the (faked) SSO UI. Guarded by the build mode, so it is +// inert in dev and production bundles. See docs/adr/0005 and tests/e2e. +/* v8 ignore start -- e2e-only seam, never exercised by unit tests */ +if (import.meta.env.MODE === 'e2e') { + ;(window as unknown as { __APP_STORE__: typeof useAppStore }).__APP_STORE__ = useAppStore +} +/* v8 ignore stop */ diff --git a/src/ui/components/DayTimeline.tsx b/src/ui/components/DayTimeline.tsx index d3ed9e7..5f1cd15 100644 --- a/src/ui/components/DayTimeline.tsx +++ b/src/ui/components/DayTimeline.tsx @@ -191,6 +191,7 @@ export function DayTimeline({ return ( - + + )} diff --git a/src/ui/components/MonthPickerPopup.tsx b/src/ui/components/MonthPickerPopup.tsx index 66fb64c..d608392 100644 --- a/src/ui/components/MonthPickerPopup.tsx +++ b/src/ui/components/MonthPickerPopup.tsx @@ -155,6 +155,7 @@ export function MonthPickerPopup({ initialMonth, onSelectDate, onClose, isDateSu return (