diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a4cac..20c941d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,43 @@ jobs: node-version: 24 - run: bun install --frozen-lockfile - run: bun run test + + e2e: + 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: --force chrome + projects: --project=desktop-chrome --project=phone + - browser: edge + install: --force 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 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + 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 ${{ 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) + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-${{ matrix.browser }} + 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..2afeb83 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 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 ``` `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 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/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..08f95d4 --- /dev/null +++ b/e2e/desktop.spec.ts @@ -0,0 +1,141 @@ +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"); + + // 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, 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. + await page.mouse.move(from.x + 60, from.y + 30, { steps: 5 }); + 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, 2)).toContainText("Dinner at Spinola Bay"); + await expect(day(page, 1)).not.toContainText("Dinner at Spinola Bay"); + await page.reload(); + 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 }) => { + 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..5fb9a32 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,84 @@ +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; + +/** + * 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, + 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: { + // A service worker would fetch past the stubs in e2e/fixtures.ts. + serviceWorkers: "block", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + ...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: [ + { + 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, + }, + ], +});