From 9a0535b749536f9f0b0faf8a5c27913133ac8b3b Mon Sep 17 00:00:00 2001 From: Krzysztof Liszankowski <39725441+Torgh@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:04:49 +0200 Subject: [PATCH 1/4] End-to-end tests: both apps in a real browser, in CI on every pull request Playwright builds and starts the desktop app (production build) and the phone app (Vite build) and drives them: - desktop: the demo trip (days, to-dos, budget, map), dragging a card to another day and keeping it after a reload, the card editor with price hints, Paid and delete, the to-do list, the budget dialog (paid ticks, currency and limit conversion), trip settings, a map pin highlighting its card, JSON export that parses back, the print page, and saving the trip to disk - phone: days, swiping, the budget tab and a budget row leading to its day, and importing a trip file They run offline and without keys: the browser gets a plain map style and fixed exchange rates, and the app's route, place and AI lookups answer as if offline. The data folder is a temporary one. Any uncaught error in a page fails the test. CI uploads screenshots and traces of failed tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 24 +++++++ .gitignore | 5 ++ README.md | 5 +- bun.lock | 7 ++ e2e/desktop.spec.ts | 138 +++++++++++++++++++++++++++++++++++++++ e2e/fixtures.ts | 59 +++++++++++++++++ e2e/phone.spec.ts | 49 ++++++++++++++ package.json | 4 +- playwright.config.ts | 71 ++++++++++++++++++++ 9 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 e2e/desktop.spec.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/phone.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a4cac..93d78e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,27 @@ jobs: node-version: 24 - run: bun install --frozen-lockfile - run: bun run test + + e2e: + name: End-to-end tests (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - run: bun install --frozen-lockfile + - run: bunx playwright install --with-deps chromium + # Builds and starts both apps (playwright.config.ts), then drives them in Chromium – offline, no keys. + - run: bun run test:e2e + - name: Upload the report (screenshots and traces of failed tests) + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 605dbcd..0939d66 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ Thumbs.db # phone app build output (Capacitor copies it into mobile/android) mobile/dist/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ diff --git a/README.md b/README.md index 437ec81..42fb0df 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,15 @@ bun run typecheck bun run lint # ESLint (Next.js + TypeScript rules), no warnings allowed bun run test # unit, API and MCP integration tests bun run test:coverage +bun run test:e2e # end-to-end: both apps in a browser (Edge on Windows, else `bunx playwright install chromium` once) bun run check # everything CI runs on a pull request: typecheck, lint, tests with coverage, both builds ``` `bun run typecheck` uses TypeScript 7 (the `@typescript/native` package, with the `tsc` command). ESLint and `next build` still need the TypeScript API, which 7.0 doesn't have, so the `typescript` package is the TypeScript 6 compatibility release (`@typescript/typescript6`), as Microsoft recommends until typescript-eslint supports 7. -CI runs the same steps as `bun run check` on every pull request and on every commit to `main`, plus the tests on Windows; a pull request can only be merged when both pass. Dependabot opens grouped update pull requests every Monday, and security fixes as soon as they are published. It fails when coverage drops below the thresholds in `vitest.config.mts`. Tests never touch your real trips: each suite uses its own temporary `TRIPDECK_HOME`, and the OneDrive folder is replaced with a temporary one. +CI runs the same steps as `bun run check` on every pull request and on every commit to `main`, plus the tests on Windows and the end-to-end tests; a pull request can only be merged when they pass. Dependabot opens grouped update pull requests every Monday, and security fixes as soon as they are published. It fails when coverage drops below the thresholds in `vitest.config.mts`. Tests never touch your real trips: each suite uses its own temporary `TRIPDECK_HOME`, and the OneDrive folder is replaced with a temporary one. + +The end-to-end tests (`e2e/`) build and start both apps and drive them in a real browser: the board with its demo trip, dragging a card, the card editor, the budget, trip settings, the map, export and print, saving to disk, and the phone app's days, budget and file import. They run offline: the browser gets a plain map style, fixed exchange rates, and the app's route, place and AI lookups answer as if offline, so no keys or internet are needed. Their data folder is a temporary one, never your `~/.tripdeck`. A failing run in CI uploads a report with screenshots and a trace of each failed test. `bun install` points git at the hooks in `.githooks/`: they refuse a commit or push that contains the value of any key, secret, token or password from your `.env.local`. diff --git a/bun.lock b/bun.lock index fb490bb..8203f80 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ }, "devDependencies": { "@capacitor/cli": "^8.5.3", + "@playwright/test": "^1.64.1", "@types/google.maps": "^3.66.7", "@types/node": "^26.6.5", "@types/react": "^19.3.0", @@ -277,6 +278,8 @@ "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], + "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.13", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.13", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="], @@ -1045,6 +1048,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="], + + "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="], + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], diff --git a/e2e/desktop.spec.ts b/e2e/desktop.spec.ts new file mode 100644 index 0000000..badf852 --- /dev/null +++ b/e2e/desktop.spec.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { E2E_HOME } from "../playwright.config"; +import { parseTripFile } from "../lib/tripOps"; +import { expect, test } from "./fixtures"; + +/** Loads the demo trip from the home page and returns its id. */ +async function loadDemo(page: Page): Promise { + await page.goto("/"); + await page.getByRole("button", { name: "Load demo trip" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Malta & Gozo dive week (demo)"); + return new URL(page.url()).searchParams.get("id")!; +} + +const header = (page: Page) => page.locator(".topbar"); +const day = (page: Page, n: number) => page.locator("section.column").filter({ has: page.getByRole("heading", { name: new RegExp(`^Day ${n} ·`) }) }); +const card = (page: Page, title: string) => page.locator("article.item").filter({ hasText: title }); + +test("loads the demo with its days, to-dos, budget and map", async ({ page }) => { + await loadDemo(page); + await expect(page.getByRole("heading", { name: /^Day \d+ ·/ })).toHaveCount(7); + await expect(header(page)).toContainText("📝 2 to sort out"); + await expect(header(page)).toContainText("💰 €1,307 of €1,500"); + await expect(header(page)).toContainText(/⚠️ \d+ warning/); + await expect(card(page, "St John's Co-Cathedral")).toContainText("Closed on this weekday"); + await expect(page.locator(".maplibregl-marker").first()).toBeVisible(); +}); + +test("moves a card to another day by dragging, and keeps it after a reload", async ({ page }) => { + await loadDemo(page); + const dinner = card(page, "Dinner at Spinola Bay"); + await expect(day(page, 1)).toContainText("Dinner at Spinola Bay"); + + const from = (await dinner.boundingBox())!; + const to = (await day(page, 3).locator(".column-head").boundingBox())!; + await page.mouse.move(from.x + 40, from.y + 15); + await page.mouse.down(); + // The board starts dragging after the pointer moves a few pixels; then glide over day 3. + await page.mouse.move(from.x + 60, from.y + 30, { steps: 5 }); + await page.mouse.move(to.x + to.width / 2, to.y + to.height + 40, { steps: 25 }); + await page.mouse.up(); + + await expect(day(page, 3)).toContainText("Dinner at Spinola Bay"); + await expect(day(page, 1)).not.toContainText("Dinner at Spinola Bay"); + await page.reload(); + await expect(day(page, 3)).toContainText("Dinner at Spinola Bay"); +}); + +test("adds a card with a price, marks it paid and deletes it again", async ({ page }) => { + await loadDemo(page); + await day(page, 2).getByRole("button", { name: "+ Add" }).click(); + const editor = page.getByRole("dialog"); + await editor.getByLabel("Title").fill("Gelato at Valletta"); + const price = editor.getByLabel("Price"); + await price.fill("1500"); + await expect(editor).toContainText("Add the currency"); + await price.fill("€12.50"); + await expect(editor).toContainText("Budget: €12.50"); + await editor.getByLabel("Paid").check(); + await editor.getByRole("button", { name: "Save" }).click(); + + await expect(day(page, 2)).toContainText("Gelato at Valletta"); + await expect(header(page)).toContainText("💰 €1,320 of €1,500"); + + page.once("dialog", (d) => d.accept()); + await card(page, "Gelato at Valletta").dblclick(); + await page.getByRole("dialog").getByRole("button", { name: "Delete" }).click(); + await expect(day(page, 2)).not.toContainText("Gelato at Valletta"); + await expect(header(page)).toContainText("💰 €1,307 of €1,500"); +}); + +test("shows what is left to sort out", async ({ page }) => { + await loadDemo(page); + await header(page).getByRole("button", { name: "📝 2 to sort out" }).click(); + const todo = page.getByRole("dialog"); + await expect(todo).toContainText("Waiting for confirmation"); + await expect(todo).toContainText("To book"); +}); + +test("budget: paid ticks, another currency, the limit converted with it", async ({ page }) => { + await loadDemo(page); + await header(page).getByRole("button", { name: /💰/ }).click(); + const budget = page.getByRole("dialog"); + await expect(budget).toContainText("€609 paid · €698 to pay"); + + await budget.locator("li", { hasText: "Rental car" }).getByLabel("Paid").check(); + await expect(budget).toContainText("€777 paid · €530 to pay"); + + // 1 EUR = 4.25 PLN in the tests: €1,307 → zł 5,555, and the €1,500 limit → zł 6,375. + await budget.getByLabel("Currency").selectOption("PLN"); + await expect(budget.locator(".budget-amount")).toContainText("5,555"); + await expect(budget).toContainText("6,375"); +}); + +test("trip settings: a later last day adds days", async ({ page }) => { + await loadDemo(page); + await header(page).getByRole("button", { name: /Trip/ }).click(); + const settings = page.getByRole("dialog"); + const last = settings.getByLabel("Last day"); + const end = new Date(`${await last.inputValue()}T00:00:00Z`); + end.setUTCDate(end.getUTCDate() + 1); + await last.fill(end.toISOString().slice(0, 10)); + await expect(settings).toContainText("8 days · 1 empty days added at the end"); + await settings.getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("heading", { name: /^Day \d+ ·/ })).toHaveCount(8); +}); + +test("a pin on the map highlights its card", async ({ page }) => { + await loadDemo(page); + // Pins overlap where cards are close together, so the click goes to the pin itself. + await page.locator(".maplibregl-marker").first().dispatchEvent("click"); + await expect(page.locator("article.item.highlight")).toHaveCount(1); +}); + +test("exports the trip as JSON that imports again, and prints the itinerary", async ({ page }) => { + const id = await loadDemo(page); + await header(page).getByRole("button", { name: /Export/ }).click(); + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: /JSON/ }).click(); + const file = await (await download).path(); + const trip = parseTripFile(JSON.parse(fs.readFileSync(file, "utf8")), { keepId: true }); + expect(trip.id).toBe(id); + expect(trip.days).toHaveLength(7); + expect(trip.budget).toEqual({ currency: "EUR", limit: 1500 }); + + await page.goto(`/trip/print?id=${id}`); + await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible(); + await expect(page.locator("p", { hasText: "Total:" })).toContainText("€1,307 · €609 paid"); +}); + +test("saves the trip on this PC", async ({ page }) => { + const id = await loadDemo(page); + await expect(page.getByText("Saved on this PC")).toBeVisible(); + const file = path.join(E2E_HOME, "trips", `${id}.json`); + await expect.poll(() => fs.existsSync(file)).toBe(true); + expect(JSON.parse(fs.readFileSync(file, "utf8")).name).toBe("Malta & Gozo dive week (demo)"); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..a47aead --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,59 @@ +import { expect, test as base, type BrowserContext, type Page } from "@playwright/test"; + +export { expect }; + +/** Exchange rates for every test, from 1 EUR – so conversions are known in advance. */ +const EUR_RATES: Record = { EUR: 1, PLN: 4.25, THB: 38, USD: 1.1 }; + +function ratesFrom(base: string) { + const from = EUR_RATES[base] ?? 1; + const rates = Object.fromEntries( + Object.entries(EUR_RATES) + .filter(([code]) => code !== base) + .map(([code, perEur]) => [code, perEur / from]), + ); + return { amount: 1, base, date: "2026-10-22", rates }; +} + +/** A plain map style: no tiles to download, the pins still show. */ +const MAP_STYLE = { + version: 8, + sources: {}, + layers: [{ id: "land", type: "background", paint: { "background-color": "#e8eef2" } }], +}; + +const isLocal = (url: URL) => ["127.0.0.1", "localhost"].includes(url.hostname); + +/** + * No outside services: anything leaving this machine is blocked, the map gets a plain style, and the + * exchange rates are the ones above. The desktop app's own routes that call Google or OpenStreetMap + * answer as if offline, so the board shows straight-line estimates – as it does without internet. + */ +async function offline(context: BrowserContext) { + // Later routes take precedence, so the catch-all comes first. + await context.route((url) => !isLocal(url), (route) => route.abort("internetdisconnected")); + await context.route("https://tiles.openfreemap.org/styles/**", (route) => route.fulfill({ json: MAP_STYLE })); + await context.route("https://api.frankfurter.dev/**", (route) => + route.fulfill({ json: ratesFrom(new URL(route.request().url()).searchParams.get("base") ?? "EUR") }), + ); + await context.route(/\/api\/(route|elevation|timezone|places|ai)(\/|$|\?)/, (route) => + route.fulfill({ status: 503, json: { error: "Offline in the end-to-end tests." } }), + ); + await context.route(/\/api\/rates$/, (route) => + route.fulfill({ json: ratesFrom((route.request().postDataJSON() as { base: string }).base) }), + ); +} + +export const test = base.extend<{ page: Page }>({ + // Every test runs offline and fails on an error in the page (blocked requests aside). + page: async ({ page }, run) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + await offline(page.context()); + await run(page); + expect(errors, "errors in the page").toEqual([]); + }, +}); diff --git a/e2e/phone.spec.ts b/e2e/phone.spec.ts new file mode 100644 index 0000000..7584d49 --- /dev/null +++ b/e2e/phone.spec.ts @@ -0,0 +1,49 @@ +import type { Page } from "@playwright/test"; +import { demoTrip } from "../lib/demo"; +import { expect, test } from "./fixtures"; + +const dayHead = (page: Page) => page.locator(".day-head h2"); + +/** A horizontal swipe across the plan (the app turns it into the next or previous day). */ +async function swipe(page: Page, dx: number) { + await page.locator("main.content").evaluate((el, dx) => { + const at = (x: number) => new Touch({ identifier: 1, target: el, clientX: x, clientY: 400 }); + el.dispatchEvent(new TouchEvent("touchstart", { touches: [at(200)], changedTouches: [at(200)], bubbles: true })); + el.dispatchEvent(new TouchEvent("touchend", { touches: [], changedTouches: [at(200 + dx)], bubbles: true })); + }, dx); +} + +test("shows the demo day by day, swipes between days and opens the budget", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /Load demo trip/ }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Malta & Gozo dive week (demo)"); + + await page.locator(".day-chip").first().click(); + await expect(dayHead(page)).toHaveText(/^Day 1 ·/); + await expect(page.locator(".day-head")).toContainText("💰 €777"); + await expect(page.locator(".card", { hasText: "Flight to Malta" })).toContainText("€189 ✓ paid"); + + await swipe(page, -150); + await expect(dayHead(page)).toHaveText(/^Day 2 ·/); + await swipe(page, 150); + await expect(dayHead(page)).toHaveText(/^Day 1 ·/); + + await page.locator(".day-chip.budget").click(); + await expect(page.locator(".budget-amount")).toHaveText("€1,307"); + await expect(page.locator(".budget-total")).toContainText("€193 left of €1,500"); + // A row of the budget leads to its day. + await page.locator(".budget-list li", { hasText: "Comino Blue Lagoon boat trip" }).click(); + await expect(dayHead(page)).toHaveText(/^Day 5 ·/); +}); + +test("imports a trip file exported on the desktop", async ({ page }) => { + const trip = { ...demoTrip(), name: "Imported Malta" }; + await page.goto("/"); + await page.locator("input[type=file]").setInputFiles({ + name: "Imported Malta.tripdeck.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(trip)), + }); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Imported Malta"); + await expect(page.getByText("Imported “Imported Malta”")).toBeVisible(); +}); diff --git a/package.json b/package.json index 4d5ffe5..28390b9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "eslint . --max-warnings 0", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", "check": "bun run typecheck && bun run lint && bun run test:coverage && bun run build && bun run build:mobile" }, "engines": { @@ -45,11 +46,12 @@ }, "devDependencies": { "@capacitor/cli": "^8.5.3", - "@typescript/native": "npm:typescript@^7.0.2", + "@playwright/test": "^1.64.1", "@types/google.maps": "^3.66.7", "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "^6.1.2", "@vitest/coverage-v8": "5.0.5", "eslint": "^10", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f486006 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +/** + * End-to-end tests: the real apps in a real browser. The desktop app runs as a production build, + * the phone app as its Vite build. Outside services (routes, places, maps, exchange rates) are + * replaced in the browser (e2e/fixtures.ts), so the tests need no keys and no internet. + */ + +/** The desktop app's data folder (trips, settings, caches): a temporary one, never your ~/.tripdeck. */ +export const E2E_HOME = path.join(os.tmpdir(), "tripdeck-e2e"); + +// Playwright loads this file in every worker too; only the main process starts from an empty folder. +if (!process.env.TEST_WORKER_INDEX) { + fs.rmSync(E2E_HOME, { recursive: true, force: true }); + fs.mkdirSync(E2E_HOME, { recursive: true }); + fs.writeFileSync(path.join(E2E_HOME, "config.json"), JSON.stringify({ storage: "local" })); +} + +const DESKTOP = "http://127.0.0.1:3210"; +const PHONE = "http://localhost:5210"; +// Set (even empty) variables win over .env.local, so your keys never reach the apps under test. +const NO_KEYS = { + GOOGLE_MAPS_API_KEY: "", + NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: "", + ANTHROPIC_API_KEY: "", + GOOGLE_CLIENT_ID: "", + GOOGLE_CLIENT_SECRET: "", + NEXT_TELEMETRY_DISABLED: "1", +}; +const CI = !!process.env.CI; + +export default defineConfig({ + testDir: "e2e", + forbidOnly: CI, + retries: CI ? 1 : 0, + workers: CI ? 2 : undefined, + reporter: CI ? [["github"], ["html", { open: "never" }]] : [["list"], ["html", { open: "never" }]], + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + // CI installs Playwright's Chromium; on Windows the installed Edge does without the download. + channel: !CI && process.platform === "win32" ? "msedge" : undefined, + // A service worker would fetch past the stubs in e2e/fixtures.ts. + serviceWorkers: "block", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "desktop", testMatch: /desktop\.spec\.ts/, use: { baseURL: DESKTOP, viewport: { width: 1400, height: 900 } } }, + { name: "phone", testMatch: /phone\.spec\.ts/, use: { ...devices["Pixel 7"], baseURL: PHONE } }, + ], + webServer: [ + { + command: "bun run build && bunx next start -H 127.0.0.1 -p 3210", + url: DESKTOP, + env: { ...NO_KEYS, TRIPDECK_HOME: E2E_HOME }, + reuseExistingServer: !CI, + timeout: 300_000, + }, + { + command: "bun run build:mobile && bunx vite preview --config mobile/vite.config.mts --port 5210 --strictPort", + url: PHONE, + env: NO_KEYS, + reuseExistingServer: !CI, + timeout: 120_000, + }, + ], +}); From 49261bb49a62cbf388a336db6bae7562d92b2429 Mon Sep 17 00:00:00 2001 From: Krzysztof Liszankowski <39725441+Torgh@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:08:10 +0200 Subject: [PATCH 2/4] E2E: scroll the dragged card into view and drag it to the day next door On Linux the card was cut off at the bottom of its day, so the mouse selected text instead of starting a drag. The test now also checks that the drag started. Co-Authored-By: Claude Opus 5.5 --- e2e/desktop.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/e2e/desktop.spec.ts b/e2e/desktop.spec.ts index badf852..08f95d4 100644 --- a/e2e/desktop.spec.ts +++ b/e2e/desktop.spec.ts @@ -32,19 +32,22 @@ test("moves a card to another day by dragging, and keeps it after a reload", asy const dinner = card(page, "Dinner at Spinola Bay"); await expect(day(page, 1)).toContainText("Dinner at Spinola Bay"); + // The card is last in a day that scrolls; bring it into view, then drag it to day 2 next door. + await dinner.scrollIntoViewIfNeeded(); const from = (await dinner.boundingBox())!; - const to = (await day(page, 3).locator(".column-head").boundingBox())!; + const to = (await day(page, 2).locator(".column-body").boundingBox())!; await page.mouse.move(from.x + 40, from.y + 15); await page.mouse.down(); - // The board starts dragging after the pointer moves a few pixels; then glide over day 3. + // The board starts dragging after the pointer moves a few pixels. await page.mouse.move(from.x + 60, from.y + 30, { steps: 5 }); - await page.mouse.move(to.x + to.width / 2, to.y + to.height + 40, { steps: 25 }); + await expect(page.locator(".item.overlay")).toBeVisible(); + await page.mouse.move(to.x + to.width / 2, Math.max(to.y, 120) + 40, { steps: 25 }); await page.mouse.up(); - await expect(day(page, 3)).toContainText("Dinner at Spinola Bay"); + await expect(day(page, 2)).toContainText("Dinner at Spinola Bay"); await expect(day(page, 1)).not.toContainText("Dinner at Spinola Bay"); await page.reload(); - await expect(day(page, 3)).toContainText("Dinner at Spinola Bay"); + await expect(day(page, 2)).toContainText("Dinner at Spinola Bay"); }); test("adds a card with a price, marks it paid and deletes it again", async ({ page }) => { From 5b42c415f85ae020b397187edec878ca3e14e764 Mon Sep 17 00:00:00 2001 From: Krzysztof Liszankowski <39725441+Torgh@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:14:53 +0200 Subject: [PATCH 3/4] E2E in Chrome, Edge and Firefox The desktop app is tested in the browsers it supports: the installed Chrome and Edge, and Playwright's Firefox. The phone app runs in Android's WebView (Chromium), so Chrome stands in for it. CI runs one job per browser, side by side. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 ++++++++++++++++++++----- README.md | 4 ++-- playwright.config.ts | 21 +++++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d78e4..db6a23f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,9 +59,23 @@ jobs: - run: bun run test e2e: - name: End-to-end tests (Playwright) + name: End-to-end tests (${{ matrix.browser }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + # Chrome and Edge are the real browsers; the phone app (Android WebView) is tested in Chrome. + - browser: chrome + install: chrome + projects: --project=desktop-chrome --project=phone + - browser: edge + install: msedge + projects: --project=desktop-edge + - browser: firefox + install: firefox + projects: --project=desktop-firefox steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -69,14 +83,15 @@ jobs: with: node-version: 24 - run: bun install --frozen-lockfile - - run: bunx playwright install --with-deps chromium - # Builds and starts both apps (playwright.config.ts), then drives them in Chromium – offline, no keys. - - run: bun run test:e2e + # --force: the runner image may already have Chrome or Edge; Playwright then reinstalls the stable build. + - run: bunx playwright install --with-deps --force ${{ matrix.install }} + # Builds and starts both apps (playwright.config.ts), then drives them – offline, no keys. + - run: bun run test:e2e ${{ matrix.projects }} - name: Upload the report (screenshots and traces of failed tests) if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: playwright-report + name: playwright-report-${{ matrix.browser }} path: | playwright-report/ test-results/ diff --git a/README.md b/README.md index 42fb0df..2afeb83 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ bun run typecheck bun run lint # ESLint (Next.js + TypeScript rules), no warnings allowed bun run test # unit, API and MCP integration tests bun run test:coverage -bun run test:e2e # end-to-end: both apps in a browser (Edge on Windows, else `bunx playwright install chromium` once) +bun run test:e2e # end-to-end: both apps in Chrome, Edge and Firefox (`bunx playwright install firefox` once) bun run check # everything CI runs on a pull request: typecheck, lint, tests with coverage, both builds ``` @@ -178,7 +178,7 @@ bun run check # everything CI runs on a pull request: typecheck, lint, test CI runs the same steps as `bun run check` on every pull request and on every commit to `main`, plus the tests on Windows and the end-to-end tests; a pull request can only be merged when they pass. Dependabot opens grouped update pull requests every Monday, and security fixes as soon as they are published. It fails when coverage drops below the thresholds in `vitest.config.mts`. Tests never touch your real trips: each suite uses its own temporary `TRIPDECK_HOME`, and the OneDrive folder is replaced with a temporary one. -The end-to-end tests (`e2e/`) build and start both apps and drive them in a real browser: the board with its demo trip, dragging a card, the card editor, the budget, trip settings, the map, export and print, saving to disk, and the phone app's days, budget and file import. They run offline: the browser gets a plain map style, fixed exchange rates, and the app's route, place and AI lookups answer as if offline, so no keys or internet are needed. Their data folder is a temporary one, never your `~/.tripdeck`. A failing run in CI uploads a report with screenshots and a trace of each failed test. +The end-to-end tests (`e2e/`) build and start both apps and drive them in the supported browsers – the desktop app in Chrome, Edge and Firefox, the phone app in Chrome (Android's WebView is Chromium). Chrome and Edge are the installed browsers; Firefox is Playwright's build. Pick one with `bun run test:e2e --project=desktop-firefox`. They cover the board with its demo trip, dragging a card, the card editor, the budget, trip settings, the map, export and print, saving to disk, and the phone app's days, budget and file import. They run offline: the browser gets a plain map style, fixed exchange rates, and the app's route, place and AI lookups answer as if offline, so no keys or internet are needed. Their data folder is a temporary one, never your `~/.tripdeck`. A failing run in CI uploads a report with screenshots and a trace of each failed test. `bun install` points git at the hooks in `.githooks/`: they refuse a commit or push that contains the value of any key, secret, token or password from your `.env.local`. diff --git a/playwright.config.ts b/playwright.config.ts index f486006..5fb9a32 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,6 +32,16 @@ const NO_KEYS = { }; const CI = !!process.env.CI; +/** + * The desktop app is tested in the browsers it supports: Chrome and Edge as installed (their own + * builds, not Playwright's Chromium) and Playwright's Firefox (`bunx playwright install firefox`). + */ +const DESKTOP_BROWSERS = [ + { name: "chrome", use: { ...devices["Desktop Chrome"], channel: "chrome" } }, + { name: "edge", use: { ...devices["Desktop Edge"], channel: "msedge" } }, + { name: "firefox", use: devices["Desktop Firefox"] }, +]; + export default defineConfig({ testDir: "e2e", forbidOnly: CI, @@ -41,16 +51,19 @@ export default defineConfig({ timeout: 60_000, expect: { timeout: 15_000 }, use: { - // CI installs Playwright's Chromium; on Windows the installed Edge does without the download. - channel: !CI && process.platform === "win32" ? "msedge" : undefined, // A service worker would fetch past the stubs in e2e/fixtures.ts. serviceWorkers: "block", trace: "retain-on-failure", screenshot: "only-on-failure", }, projects: [ - { name: "desktop", testMatch: /desktop\.spec\.ts/, use: { baseURL: DESKTOP, viewport: { width: 1400, height: 900 } } }, - { name: "phone", testMatch: /phone\.spec\.ts/, use: { ...devices["Pixel 7"], baseURL: PHONE } }, + ...DESKTOP_BROWSERS.map(({ name, use }) => ({ + name: `desktop-${name}`, + testMatch: /desktop\.spec\.ts/, + use: { ...use, baseURL: DESKTOP, viewport: { width: 1400, height: 900 } }, + })), + // The phone app runs in Android's WebView, which is Chromium: Chrome stands in for it. + { name: "phone", testMatch: /phone\.spec\.ts/, use: { ...devices["Pixel 7"], channel: "chrome", baseURL: PHONE } }, ], webServer: [ { From e2fbe523a50aceacd4eeb999ab7c5f6f610d75dd Mon Sep 17 00:00:00 2001 From: Krzysztof Liszankowski <39725441+Torgh@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:25:05 +0200 Subject: [PATCH 4/4] CI: --force only for Chrome and Edge, and a 5-minute limit on installing a browser Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db6a23f..20c941d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,10 +68,10 @@ jobs: include: # Chrome and Edge are the real browsers; the phone app (Android WebView) is tested in Chrome. - browser: chrome - install: chrome + install: --force chrome projects: --project=desktop-chrome --project=phone - browser: edge - install: msedge + install: --force msedge projects: --project=desktop-edge - browser: firefox install: firefox @@ -84,7 +84,8 @@ jobs: node-version: 24 - run: bun install --frozen-lockfile # --force: the runner image may already have Chrome or Edge; Playwright then reinstalls the stable build. - - run: bunx playwright install --with-deps --force ${{ matrix.install }} + - run: bunx playwright install --with-deps ${{ matrix.install }} + timeout-minutes: 5 # Builds and starts both apps (playwright.config.ts), then drives them – offline, no keys. - run: bun run test:e2e ${{ matrix.projects }} - name: Upload the report (screenshots and traces of failed tests)