Skip to content
Closed
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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions bun.lock

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

141 changes: 141 additions & 0 deletions e2e/desktop.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();

Check failure on line 27 in e2e/desktop.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days

1) [desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days, to-dos, budget and map Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.maplibregl-marker').first() Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.maplibregl-marker').first() with timeout 15000ms - waiting for locator('.maplibregl-marker').first() 25 | await expect(header(page)).toContainText(/⚠️ \d+ warning/); 26 | await expect(card(page, "St John's Co-Cathedral")).toContainText("Closed on this weekday"); > 27 | await expect(page.locator(".maplibregl-marker").first()).toBeVisible(); | ^ 28 | }); 29 | 30 | test("moves a card to another day by dragging, and keeps it after a reload", async ({ page }) => { at /home/runner/work/tripdeck/tripdeck/e2e/desktop.spec.ts:27:60

Check failure on line 27 in e2e/desktop.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days

1) [desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days, to-dos, budget and map Error: expect(locator).toBeVisible() failed Locator: locator('.maplibregl-marker').first() Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.maplibregl-marker').first() with timeout 15000ms - waiting for locator('.maplibregl-marker').first() 25 | await expect(header(page)).toContainText(/⚠️ \d+ warning/); 26 | await expect(card(page, "St John's Co-Cathedral")).toContainText("Closed on this weekday"); > 27 | await expect(page.locator(".maplibregl-marker").first()).toBeVisible(); | ^ 28 | }); 29 | 30 | test("moves a card to another day by dragging, and keeps it after a reload", async ({ page }) => { at /home/runner/work/tripdeck/tripdeck/e2e/desktop.spec.ts:27:60
});

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();

Check failure on line 43 in e2e/desktop.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging

2) [desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging, and keeps it after a reload Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.item.overlay') Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.item.overlay') with timeout 15000ms - waiting for locator('.item.overlay') 41 | // The board starts dragging after the pointer moves a few pixels. 42 | await page.mouse.move(from.x + 60, from.y + 30, { steps: 5 }); > 43 | await expect(page.locator(".item.overlay")).toBeVisible(); | ^ 44 | await page.mouse.move(to.x + to.width / 2, Math.max(to.y, 120) + 40, { steps: 25 }); 45 | await page.mouse.up(); 46 | at /home/runner/work/tripdeck/tripdeck/e2e/desktop.spec.ts:43:47

Check failure on line 43 in e2e/desktop.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging

2) [desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging, and keeps it after a reload Error: expect(locator).toBeVisible() failed Locator: locator('.item.overlay') Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.item.overlay') with timeout 15000ms - waiting for locator('.item.overlay') 41 | // The board starts dragging after the pointer moves a few pixels. 42 | await page.mouse.move(from.x + 60, from.y + 30, { steps: 5 }); > 43 | await expect(page.locator(".item.overlay")).toBeVisible(); | ^ 44 | await page.mouse.move(to.x + to.width / 2, Math.max(to.y, 120) + 40, { steps: 25 }); 45 | await page.mouse.up(); 46 | at /home/runner/work/tripdeck/tripdeck/e2e/desktop.spec.ts:43:47
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)");
});
59 changes: 59 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = { 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([]);

Check failure on line 57 in e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:53:5 › adds a card with a price

3) [desktop-firefox] › e2e/desktop.spec.ts:53:5 › adds a card with a price, marks it paid and deletes it again Error: errors in the page expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2, a technology for rendering 3D graphics on the web. Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL", + ] at fixtures.ts:57 55 | await offline(page.context()); 56 | await run(page); > 57 | expect(errors, "errors in the page").toEqual([]); | ^ 58 | }, 59 | }); 60 | at Object.page (/home/runner/work/tripdeck/tripdeck/e2e/fixtures.ts:57:42)

Check failure on line 57 in e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging

2) [desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging, and keeps it after a reload Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: errors in the page expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2, a technology for rendering 3D graphics on the web. Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL", + ] at fixtures.ts:57 55 | await offline(page.context()); 56 | await run(page); > 57 | expect(errors, "errors in the page").toEqual([]); | ^ 58 | }, 59 | }); 60 | at Object.page (/home/runner/work/tripdeck/tripdeck/e2e/fixtures.ts:57:42)

Check failure on line 57 in e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging

2) [desktop-firefox] › e2e/desktop.spec.ts:30:5 › moves a card to another day by dragging, and keeps it after a reload Error: errors in the page expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2, a technology for rendering 3D graphics on the web. Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL", + ] at fixtures.ts:57 55 | await offline(page.context()); 56 | await run(page); > 57 | expect(errors, "errors in the page").toEqual([]); | ^ 58 | }, 59 | }); 60 | at Object.page (/home/runner/work/tripdeck/tripdeck/e2e/fixtures.ts:57:42)

Check failure on line 57 in e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days

1) [desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days, to-dos, budget and map Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: errors in the page expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2, a technology for rendering 3D graphics on the web. Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL", + ] at fixtures.ts:57 55 | await offline(page.context()); 56 | await run(page); > 57 | expect(errors, "errors in the page").toEqual([]); | ^ 58 | }, 59 | }); 60 | at Object.page (/home/runner/work/tripdeck/tripdeck/e2e/fixtures.ts:57:42)

Check failure on line 57 in e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / End-to-end tests (firefox)

[desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days

1) [desktop-firefox] › e2e/desktop.spec.ts:20:5 › loads the demo with its days, to-dos, budget and map Error: errors in the page expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "WebGL2 is required to display this map. We are sorry, but it seems that your browser does not support WebGL2, a technology for rendering 3D graphics on the web. Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL", + ] at fixtures.ts:57 55 | await offline(page.context()); 56 | await run(page); > 57 | expect(errors, "errors in the page").toEqual([]); | ^ 58 | }, 59 | }); 60 | at Object.page (/home/runner/work/tripdeck/tripdeck/e2e/fixtures.ts:57:42)
},
});
49 changes: 49 additions & 0 deletions e2e/phone.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading