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
8 changes: 8 additions & 0 deletions .env.e2e
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0005-e2e-playwright-mocked-tauri-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
9 changes: 9 additions & 0 deletions src/store/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,12 @@ export const useAppStore = create<AppState>()((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 */
8 changes: 6 additions & 2 deletions src/ui/components/DayTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function DayTimeline({
return (
<button
key={key}
data-testid={`entry-block-${block.entry.id}`}
onClick={readOnly ? undefined : () => onEditEntry(block.entry)}
style={{
...baseStyle,
Expand Down Expand Up @@ -239,10 +240,11 @@ export function DayTimeline({
: `${block.block.confidence}/5`
const canDelete = !readOnly && onDeleteConcept !== undefined
return (
<div key={key} className="group" style={baseStyle}>
<div key={key} className="group" data-testid={`concept-block-${block.block.urlPattern}`} style={baseStyle}>
{canDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDeleteConcept!(block.block) }}
data-testid={`concept-delete-${block.block.urlPattern}`}
title="Verwijderen uit dag"
className="opacity-0 group-hover:opacity-100 transition-opacity"
style={{ position: 'absolute', top: 2, right: 3, zIndex: 5, width: 18, height: 18, borderRadius: 4, border: 'none', background: 'rgba(255,255,255,.85)', color: '#ef4444', fontSize: 12, fontWeight: 700, lineHeight: 1, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
Expand Down Expand Up @@ -396,7 +398,7 @@ export function DayTimeline({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{readOnly && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: '#f0fdf4', color: '#15803d', border: '1px solid #86efac', borderRadius: 6, padding: '3px 8px', fontSize: 10, fontWeight: 700 }}>
<span data-testid="day-readonly-badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: '#f0fdf4', color: '#15803d', border: '1px solid #86efac', borderRadius: 6, padding: '3px 8px', fontSize: 10, fontWeight: 700 }}>
🔒 Ingediend · alleen-lezen
</span>
)}
Expand All @@ -422,6 +424,7 @@ export function DayTimeline({
{onProcessDay && (
<button
onClick={onProcessDay}
data-testid="process-day"
className="bg-[#4f46e5] hover:bg-[#4338ca] text-white text-[0.625rem] font-semibold px-3 py-[5px] rounded-lg transition-colors cursor-pointer flex-shrink-0"
>
▶ Verwerk dag
Expand Down Expand Up @@ -527,6 +530,7 @@ export function DayTimeline({
{/* Blokken — omhullende div voor drag */}
<div
ref={blocksContainerRef}
data-testid="timeline-canvas"
className="flex-1 relative"
style={{
minHeight: TOTAL_PX,
Expand Down
5 changes: 3 additions & 2 deletions src/ui/components/LeftoverSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function LeftoverSidebar({ leftovers, onBook, onDismiss, readOnly = false
return (
<div
key={block.urlPattern}
data-testid={`leftover-${block.urlPattern}`}
onMouseEnter={() => setHovered(block.urlPattern)}
onMouseLeave={() => setHovered(prev => (prev === block.urlPattern ? null : prev))}
style={{
Expand All @@ -132,8 +133,8 @@ export function LeftoverSidebar({ leftovers, onBook, onDismiss, readOnly = false
</div>
{showActions && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
<button onClick={() => onBook(block)} title="Direct boeken" style={iconBtn('#fff', '#16a34a', true)}>✓</button>
<button onClick={() => onDismiss(block)} title="Negeren" style={iconBtn('var(--text-muted)', 'var(--bg)')}>✕</button>
<button data-testid={`leftover-book-${block.urlPattern}`} onClick={() => onBook(block)} title="Direct boeken" style={iconBtn('#fff', '#16a34a', true)}>✓</button>
<button data-testid={`leftover-dismiss-${block.urlPattern}`} onClick={() => onDismiss(block)} title="Negeren" style={iconBtn('var(--text-muted)', 'var(--bg)')}>✕</button>
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/MonthPickerPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export function MonthPickerPopup({ initialMonth, onSelectDate, onClose, isDateSu
return (
<button
key={cell.date}
data-testid={`picker-day-${cell.date}`}
disabled={cell.isWeekend}
onClick={() => onSelectDate(cell.date)}
title={isSubmitted ? 'Ingediend' : undefined}
Expand Down
2 changes: 2 additions & 0 deletions src/ui/components/SearchableSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function SearchableSelect({ label, options, value, onChange, required, di
<button
ref={triggerRef}
type="button"
data-testid={`select-${label.toLowerCase()}`}
onClick={handleOpen}
disabled={disabled}
className={`w-full bg-[var(--surface)] text-left text-sm rounded-lg px-3 py-2 border focus:outline-none disabled:opacity-50 flex items-center justify-between gap-2 ${highlight ? 'border-[#a07848] focus:border-[#a07848]' : 'border-[var(--border)] focus:border-[var(--border-strong)]'}`}
Expand Down Expand Up @@ -139,6 +140,7 @@ export function SearchableSelect({ label, options, value, onChange, required, di
<div className="flex items-center">
<button
type="button"
data-testid={`option-${opt.id}`}
onClick={() => handleSelect(opt.id)}
className={`flex-1 text-left px-3 py-2 text-sm hover:bg-[var(--bg)] transition-colors ${
opt.id === value ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-primary)]'
Expand Down
Loading
Loading