From 7b9ac38726dd9eb7468bdebadde90eb97754ca5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sat, 13 Jun 2026 08:57:27 +0200 Subject: [PATCH 01/21] docs: add spec for page component refactoring --- specs/pending/refactor-page-components-v1.md | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 specs/pending/refactor-page-components-v1.md diff --git a/specs/pending/refactor-page-components-v1.md b/specs/pending/refactor-page-components-v1.md new file mode 100644 index 0000000..2e8e879 --- /dev/null +++ b/specs/pending/refactor-page-components-v1.md @@ -0,0 +1,81 @@ +# Specification: Page Component Refactoring + +## Goal + +Refactor `src/app/page.tsx` to follow a clean architecture by extracting logic-heavy UI sections into "dumb", presentational components. This will improve testability, maintainability, and readability. + +## Current State + +The `Home` component in `src/app/page.tsx` is a "God Component" handling: + +- State orchestration (via `useTrips`). +- API Key management and local storage syncing. +- AI Service calls. +- File I/O (JSON Import/Export). +- Complex Markdown rendering logic (including iframe security). +- Layout and styling. + +## Proposed Architecture + +Extract UI logic into 6 functional components in `src/components/`. The `Home` page will act as a "Smart Container" that manages data and passes handlers. + +### 1. `Navbar` + +- **Props**: `apiKey: string`, `onApiKeyChange: (val: string) => void`. +- **Content**: Logo, versioning, GitHub link, and API Token input. + +### 2. `GenerationForm` + +- **Props**: `prompt: string`, `onPromptChange: (val: string) => void`, `onGenerate: () => void`, `isLoading: boolean`, `error: string`. +- **Content**: Prompt input field and generation button. + +### 3. `WorkspaceActions` + +- **Props**: `totalTrips: number`, `onExport: () => void`, `onImport: (e: React.ChangeEvent) => void`, `onShare: () => void`. +- **Content**: Export, Import (with hidden file input), and Share buttons. + +### 4. `TripNavigator` + +- **Props**: `activeTrip: Trip`, `activeIndex: number`, `totalTrips: number`, `onNext: () => void`, `onPrev: () => void`. +- **Content**: Pagination controls and trip metadata (destination/date). + +### 5. `TripViewer` (formerly TripContent) + +- **Props**: `content: string`, `onDelete: () => void`. +- **Content**: Markdown renderer with custom components for Google Maps iframes and specialized link styling. + +### 6. `EmptyState` + +- **Props**: None. +- **Content**: Placeholder UI when no trips are present. + +## Implementation Plan + +This refactoring will be executed incrementally. For each component identified in the architecture: + +1. **Extract**: Create the component in `src/components/`. +2. **Test**: Create a corresponding unit test (e.g., `src/components/__tests__/ComponentName.test.tsx`). +3. **Integrate**: Update `src/app/page.tsx` to use the new component. +4. **Verify & Commit**: Run `npm run check`, then commit and push the changes for that specific component. + +### Execution Order: + +1. `EmptyState` +2. `Navbar` +3. `GenerationForm` +4. `WorkspaceActions` +5. `TripNavigator` +6. `TripViewer` + +## Branching Strategy + +- **Branch**: `feat/refactor-page-components` +- **Commits**: Atomic commits for each component extraction + test suite. + +## Testing Strategy + +1. **Unit Tests**: Create test files for each new component in `src/components/__tests__/` (if following project convention, or alongside the file). + - Verify `GenerationForm` displays the loading spinner when `isLoading` is true. + - Verify `TripViewer` correctly blocks non-Google Maps iframes. + - Verify `WorkspaceActions` buttons are disabled when `totalTrips` is 0. +2. **Regression Testing**: Run `npm run check` to ensure no type errors or linting regressions were introduced. From 08ca4139a04967d2f058bfcd092c6f82349aa7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sat, 13 Jun 2026 08:59:45 +0200 Subject: [PATCH 02/21] docs: update SDD workflow with branching and incremental protocols --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 68626b5..2458d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,11 @@ Before writing any implementation code, agents must: 2. **Plan (Spec)**: Create a new specification file in `specs/pending/`. 3. **Approve**: Present the plan to the user and wait for explicit approval. 4. **Execute**: Implement surgical changes following the approved plan. -5. **Validate**: Run `npm run check` to ensure zero regressions. + - **Branching**: Always create a feature branch (`feat/name`) or fix branch (`fix/name`) before execution. + - **Incrementality**: Extract/Implement one logical unit (e.g., one component) at a time. + - **Test-Driven**: Write unit tests for each unit immediately after creation. + - **Atomic Commits**: Commit and push each successful "Unit + Test" cycle. +5. **Validate**: Run `npm run check` to ensure zero regressions before final PR. ## πŸ›  Tech Stack Constraints From 28328775e670eda04d9073f8708c5d0e339b54c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sat, 13 Jun 2026 09:06:28 +0200 Subject: [PATCH 03/21] docs: update multi-tiered testing strategy in CONTRIBUTING.md --- CONTRIBUTING.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f75dbd9..859e048 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,9 +60,17 @@ If working without an agent, follow these steps to keep the project state synchr Beyond end-to-end testing, we use a multi-tiered strategy for component, accessibility, and visual validation: -1. **Unit Tests (`*.test.[ts|tsx]`)**: Validate logic, utilities, and basic component interaction using Vitest. - - **Unit Tests**: Must be co-located with the source file (e.g., `src/utils/share.test.ts` for `src/utils/share.ts`). -2. **E2E Tests**: Reserved for the `e2e/` directory (to be created when needed). +1. **Unit Tests (`*.test.[ts|tsx]`)**: Validate logic, utilities, and basic component interaction using Vitest and JSDOM. These are fast and do not require a browser. + - **Co-location**: Unit tests MUST be co-located with the source file they test (e.g., `src/components/Button.test.tsx` for `src/components/Button.tsx`). + - **Command**: `npm run test` (or `npm run check` for the full suite). +2. **Visual Regression Tests (`*.spec.[ts|tsx]`)**: Validate component-level rendering and pixel-perfect consistency in a real browser (Chromium) using Playwright. + - **Location**: Co-located with the component, similar to unit tests, but using the `.spec` suffix. + - **Command**: `npm run test:visual` (Future capability). +3. **E2E Tests (`e2e/*.spec.ts`)**: Validate full user workflows in a real browser environment. + - **Location**: Dedicated `e2e/` directory at the project root. + - **Command**: `npm run test:e2e` (Future capability). +4. **Storybook Interaction & A11y Tests**: Validate visual/accessibility compliance (e.g., color contrast) and component interactions in isolation. + - **Command**: `npm run test:storybook` (Future capability). ## βœ… The "Definition of Done" From ac00e7b8d23c1db0eac1b813e206464e451e5767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sat, 13 Jun 2026 09:11:03 +0200 Subject: [PATCH 04/21] feat: extract EmptyState component and add unit tests --- src/app/page.tsx | 9 ++------- src/components/EmptyState.test.tsx | 12 ++++++++++++ src/components/EmptyState.tsx | 12 ++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 src/components/EmptyState.test.tsx create mode 100644 src/components/EmptyState.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 3eccbff..e4234ed 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,6 +21,7 @@ import { generateItinerary } from '@/services/ai'; import { generateShareUrl } from '@/utils/share'; import { VERSION, REPO_URL } from '@/utils/version'; import { Logo } from '@/components/Logo'; +import { EmptyState } from '@/components/EmptyState'; export default function Home() { const { @@ -298,13 +299,7 @@ export default function Home() { ) : ( -
-

No active itineraries loaded

-

- Generate a route using your client API key or drop an imported file to populate the - board. -

-
+ )} diff --git a/src/components/EmptyState.test.tsx b/src/components/EmptyState.test.tsx new file mode 100644 index 0000000..9fcdb8f --- /dev/null +++ b/src/components/EmptyState.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import '@testing-library/jest-dom'; +import { EmptyState } from './EmptyState'; + +describe('EmptyState', () => { + it('renders the empty state message', () => { + render(); + expect(screen.getByText(/No active itineraries loaded/i)).toBeInTheDocument(); + expect(screen.getByText(/Generate a route using your client API key/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..e5339b4 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const EmptyState: React.FC = () => { + return ( +
+

No active itineraries loaded

+

+ Generate a route using your client API key or drop an imported file to populate the board. +

+
+ ); +}; From d4f747f1b0182ab68d5d758816e70a288d2deb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sat, 13 Jun 2026 12:02:39 +0200 Subject: [PATCH 05/21] feat(testing): initialize Playwright E2E suite - Set up Playwright configuration and directory structure. - Implement smoke and golden-path tests. - Update Next.js config to use conditional basePath for development stability. - Add test:e2e script and update CONTRIBUTING/README documentation. - Ignore E2E artifacts in .gitignore. --- .gitignore | 4 ++ CONTRIBUTING.md | 5 +- README.md | 2 +- e2e/golden-path.spec.ts | 31 ++++++++++++ e2e/smoke.spec.ts | 7 +++ next.config.ts | 6 ++- package-lock.json | 64 +++++++++++++++++++++++++ package.json | 2 + playwright.config.ts | 26 ++++++++++ specs/completed/e2e-testing-setup-v1.md | 31 ++++++++++++ 10 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 e2e/golden-path.spec.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 specs/completed/e2e-testing-setup-v1.md diff --git a/.gitignore b/.gitignore index 5ef6a52..5a9d324 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +/playwright-report/ +/test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859e048..6b66b92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,7 @@ If working without an agent, follow these steps to keep the project state synchr | `npm run check:fast` | Fast checks: Lint, Format, and Type-check | | `npm run fix` | Automatically fix linting and formatting issues | | `npm run test` | Run unit tests with Vitest | +| `npm run test:e2e` | Run E2E tests with Playwright | | `npm run type-check` | Validate TypeScript types | ## πŸ§ͺ Testing Strategy @@ -66,9 +67,9 @@ Beyond end-to-end testing, we use a multi-tiered strategy for component, accessi 2. **Visual Regression Tests (`*.spec.[ts|tsx]`)**: Validate component-level rendering and pixel-perfect consistency in a real browser (Chromium) using Playwright. - **Location**: Co-located with the component, similar to unit tests, but using the `.spec` suffix. - **Command**: `npm run test:visual` (Future capability). -3. **E2E Tests (`e2e/*.spec.ts`)**: Validate full user workflows in a real browser environment. +3. E2E Tests (`e2e/*.spec.ts`): Validate full user workflows in a real browser environment. - **Location**: Dedicated `e2e/` directory at the project root. - - **Command**: `npm run test:e2e` (Future capability). + - **Command**: `npm run test:e2e` 4. **Storybook Interaction & A11y Tests**: Validate visual/accessibility compliance (e.g., color contrast) and component interactions in isolation. - **Command**: `npm run test:storybook` (Future capability). diff --git a/README.md b/README.md index baca142..5f3ee23 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This project follows **Spec-Driven Development (SDD)** to maintain a clear roadm ```bash npm run dev ``` -5. **Open the App**: Visit `http://localhost:3000` and enter your API key in the top bar. +5. **Open the App**: Visit `http://localhost:3000` (development) or `http://localhost:3000/quick-tripper` (production-emulated) and enter your API key in the top bar. ## πŸ—οΈ Architecture diff --git a/e2e/golden-path.spec.ts b/e2e/golden-path.spec.ts new file mode 100644 index 0000000..2aea617 --- /dev/null +++ b/e2e/golden-path.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test('golden path - generate trip', async ({ page }) => { + await page.goto('/'); + // Mock API + await page.route('https://router.huggingface.co/v1/chat/completions', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + content: '# Golden Trip\nThis is a mocked itinerary.', + }, + }, + ], + }), + }), + ); + + // Enter API Key + await page.getByPlaceholder('HuggingFace API Token').fill('dummy-key'); + // Enter Destination + await page.getByPlaceholder('Ex: A 4-day hike').fill('Swiss Alps'); + // Send + await page.locator('.join').getByRole('button').click(); // Send icon button within join component + + // Verify + await expect(page.getByText('Golden Trip')).toBeVisible(); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..ab4e3e7 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test('homepage should have correct branding and show EmptyState', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Quick-tripper')).toBeVisible(); + await expect(page.getByText('No active itineraries loaded')).toBeVisible(); +}); diff --git a/next.config.ts b/next.config.ts index bb49c21..c95f024 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,10 @@ /** @type {import('next').NextConfig} */ +const isProd = process.env.NODE_ENV === 'production'; + const nextConfig = { output: 'export', // Indispensable pour GitHub Pages - basePath: '/quick-tripper', - assetPrefix: '/quick-tripper', + basePath: isProd ? '/quick-tripper' : '', + assetPrefix: isProd ? '/quick-tripper/' : '', images: { unoptimized: true, // RecommandΓ© pour l'export statique }, diff --git a/package-lock.json b/package-lock.json index 2dc9dbe..d8b6d79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "rehype-raw": "^7.0.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -1518,6 +1519,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -8155,6 +8172,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 60eff9b..5f054d6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "format:fix": "prettier --write .", "type-check": "tsc --noEmit", "test": "vitest run", + "test:e2e": "playwright test", "test:watch": "vitest", "check": "npm run lint && npm run format && npm run type-check && npm run test", "check:fast": "npm run lint && npm run format && npm run type-check", @@ -62,6 +63,7 @@ "rehype-raw": "^7.0.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a633d20 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:3001', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npx next dev -p 3001', + url: 'http://localhost:3001', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/specs/completed/e2e-testing-setup-v1.md b/specs/completed/e2e-testing-setup-v1.md new file mode 100644 index 0000000..31a880b --- /dev/null +++ b/specs/completed/e2e-testing-setup-v1.md @@ -0,0 +1,31 @@ +# Spec: E2E Testing Setup with Playwright (v1) + +## Status + +🟒 Completed + +## Overview + +Initialize Playwright E2E testing for Quick-tripper to ensure core functionality works end-to-end. + +## Scope + +- [x] **Installation**: Install `@playwright/test`. +- [x] **Configuration**: Create initial Playwright configuration in the root. +- [x] **Directory**: Create `e2e/` folder for test files. +- [x] **Tests**: + - [x] `e2e/smoke.spec.ts`: Verify homepage loading, branding, and EmptyState. + - [x] `e2e/golden-path.spec.ts`: Mock Hugging Face API, simulate user trip generation, verify output. +- [x] **Scripts**: Add `test:e2e` to `package.json`. +- [x] **Documentation**: Update `CONTRIBUTING.md` to include the new E2E testing strategy. + +## Verification + +- [x] Run tests using `npx playwright test`. +- [x] Ensure tests pass. +- [x] Add `test:e2e` to `package.json` and run. +- [x] Update `CONTRIBUTING.md`. + +## Change Log + +- 2026-06-13: E2E suite initialized, smoke and golden-path tests implemented, `package.json` and `CONTRIBUTING.md` updated. Tests verified passing. From 8391f742d3b128ff34ff7812cc648264769a060e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 08:31:04 +0200 Subject: [PATCH 06/21] chore: fix formatting in CI integration spec --- specs/pending/ci-e2e-integration-v1.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 specs/pending/ci-e2e-integration-v1.md diff --git a/specs/pending/ci-e2e-integration-v1.md b/specs/pending/ci-e2e-integration-v1.md new file mode 100644 index 0000000..96b2cf2 --- /dev/null +++ b/specs/pending/ci-e2e-integration-v1.md @@ -0,0 +1,15 @@ +# Spec: Integrate E2E Tests into CI/CD (v1) + +## Overview + +Include E2E tests (`npm run test:e2e`) in the `npm run check` script and ensure they run in GitHub Actions CI. + +## Scope + +1. **Scripts**: Add `test:e2e` to `npm run check` in `package.json`. +2. **CI**: Update `.github/workflows/ci.yml` to run Playwright E2E tests. + +## Verification + +1. Run `npm run check` locally to ensure it now includes E2E tests. +2. Verify CI workflow runs E2E tests. From 3e4d0dded7f8dcaff9b5803a9103f954ccbcd965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 08:40:46 +0200 Subject: [PATCH 07/21] ci: install playwright and run e2e tests --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b127b86..9243d84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,9 @@ jobs: node-version: 22 cache: 'npm' - run: npm ci + - run: npx playwright install --with-deps - run: npm run check + - run: npm run test:e2e deploy: name: 'Build and Deploy' From 0e1b7e5b2fb9068d1eb3d3cac94779e88a9f8425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 08:49:41 +0200 Subject: [PATCH 08/21] fix: exclude e2e tests from vitest --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index a86bc2c..6a6458e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + exclude: ['e2e/*', 'node_modules'], alias: { '@': path.resolve(__dirname, './src'), }, From 08ad19a34697f983e7f8b54ad72aecf3668e07c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 08:54:36 +0200 Subject: [PATCH 09/21] ci: run workflow on all branches --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9243d84..79c87c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,7 @@ name: CI on: push: - branches: [main] pull_request: - branches: [main] permissions: contents: write From 775b40adf10cdb77bd469ea2cb5bc80432e8ae22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 08:58:31 +0200 Subject: [PATCH 10/21] ci: fix redundant CI triggers on push --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79c87c5..22154a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: permissions: From b8273d64b77d95cc63e3226fc14250ffd55a834d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 09:09:48 +0200 Subject: [PATCH 11/21] feat: extract Navbar component and add unit tests --- src/app/page.tsx | 36 ++------------------------ src/components/Navbar.test.tsx | 36 ++++++++++++++++++++++++++ src/components/Navbar.tsx | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 src/components/Navbar.test.tsx create mode 100644 src/components/Navbar.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index e4234ed..c5ca713 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,6 @@ import React, { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; -import { FaKey as Key } from 'react-icons/fa'; import { IoSend as Send } from 'react-icons/io5'; import { FiDownload as Download, @@ -13,15 +12,14 @@ import { FiChevronLeft as ChevronLeft, FiChevronRight as ChevronRight, } from 'react-icons/fi'; -import { SiGithub as GitHub } from 'react-icons/si'; // Standardized Hook, Service, and Share Utilities import { useTrips } from '@/hooks/useTrips'; import { generateItinerary } from '@/services/ai'; import { generateShareUrl } from '@/utils/share'; -import { VERSION, REPO_URL } from '@/utils/version'; import { Logo } from '@/components/Logo'; import { EmptyState } from '@/components/EmptyState'; +import { Navbar } from '@/components/Navbar'; export default function Home() { const { @@ -119,37 +117,7 @@ export default function Home() { return (
-
-
- -
- Quick-tripper -
- v{VERSION} - - GitHub - -
-
-
-
- -
-
+
diff --git a/src/components/Navbar.test.tsx b/src/components/Navbar.test.tsx new file mode 100644 index 0000000..293bc8d --- /dev/null +++ b/src/components/Navbar.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { Navbar } from './Navbar'; + +describe('Navbar', () => { + it('renders branding and version', () => { + render( {}} />); + expect(screen.getByText(/Quick-tripper/i)).toBeInTheDocument(); + expect(screen.getByText(/v\d+\.\d+\.\d+/)).toBeInTheDocument(); + }); + + it('renders the API key input with correct value', () => { + render( {}} />); + const input = screen.getByPlaceholderText(/HuggingFace API Token/i); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('test-key'); + }); + + it('calls onApiKeyChange when input changes', () => { + const handleChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText(/HuggingFace API Token/i); + + fireEvent.change(input, { target: { value: 'new-key' } }); + expect(handleChange).toHaveBeenCalledWith('new-key'); + }); + + it('contains the GitHub link', () => { + render( {}} />); + const githubLink = screen.getByRole('link', { name: /GitHub/i }); + expect(githubLink).toBeInTheDocument(); + expect(githubLink).toHaveAttribute('href'); + expect(githubLink).toHaveAttribute('target', '_blank'); + }); +}); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..876a648 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { FaKey as Key } from 'react-icons/fa'; +import { SiGithub as GitHub } from 'react-icons/si'; +import { VERSION, REPO_URL } from '@/utils/version'; +import { Logo } from '@/components/Logo'; + +interface NavbarProps { + apiKey: string; + onApiKeyChange: (value: string) => void; +} + +export const Navbar: React.FC = ({ apiKey, onApiKeyChange }) => { + return ( +
+
+ +
+ Quick-tripper +
+ v{VERSION} + + GitHub + +
+
+
+
+ +
+
+ ); +}; From d295f004d2c37799b162784e0a86092fcb56ac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 09:18:07 +0200 Subject: [PATCH 12/21] feat: extract GenerationForm component and add unit tests --- specs/pending/refactor-page-components-v1.md | 12 +-- src/app/page.tsx | 38 ++------- src/components/GenerationForm.test.tsx | 81 ++++++++++++++++++++ src/components/GenerationForm.tsx | 50 ++++++++++++ 4 files changed, 145 insertions(+), 36 deletions(-) create mode 100644 src/components/GenerationForm.test.tsx create mode 100644 src/components/GenerationForm.tsx diff --git a/specs/pending/refactor-page-components-v1.md b/specs/pending/refactor-page-components-v1.md index 2e8e879..756b2de 100644 --- a/specs/pending/refactor-page-components-v1.md +++ b/specs/pending/refactor-page-components-v1.md @@ -60,12 +60,12 @@ This refactoring will be executed incrementally. For each component identified i ### Execution Order: -1. `EmptyState` -2. `Navbar` -3. `GenerationForm` -4. `WorkspaceActions` -5. `TripNavigator` -6. `TripViewer` +1. [x] `EmptyState` +2. [x] `Navbar` +3. [ ] `GenerationForm` +4. [ ] `WorkspaceActions` +5. [ ] `TripNavigator` +6. [ ] `TripViewer` ## Branching Strategy diff --git a/src/app/page.tsx b/src/app/page.tsx index c5ca713..5666035 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,6 @@ import React, { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; -import { IoSend as Send } from 'react-icons/io5'; import { FiDownload as Download, FiUpload as Upload, @@ -20,6 +19,7 @@ import { generateShareUrl } from '@/utils/share'; import { Logo } from '@/components/Logo'; import { EmptyState } from '@/components/EmptyState'; import { Navbar } from '@/components/Navbar'; +import { GenerationForm } from '@/components/GenerationForm'; export default function Home() { const { @@ -120,35 +120,13 @@ export default function Home() {
-
-
-

- Describe your next journey -

-
- setPrompt(e.target.value)} - disabled={isLoading} - className="input input-bordered join-item w-full input-md focus:outline-none" - /> - -
- {error &&

{error}

} -
-
+

diff --git a/src/components/GenerationForm.test.tsx b/src/components/GenerationForm.test.tsx new file mode 100644 index 0000000..6c476d5 --- /dev/null +++ b/src/components/GenerationForm.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { GenerationForm } from './GenerationForm'; + +describe('GenerationForm', () => { + it('renders correctly with initial props', () => { + render( + {}} + onGenerate={() => {}} + isLoading={false} + />, + ); + const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('Initial prompt'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); // spinner check + }); + + it('calls onPromptChange when input changes', () => { + const handleChange = vi.fn(); + render( + {}} + isLoading={false} + />, + ); + const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + fireEvent.change(input, { target: { value: 'New prompt' } }); + expect(handleChange).toHaveBeenCalledWith('New prompt'); + }); + + it('calls onGenerate when button is clicked', () => { + const handleGenerate = vi.fn(); + render( + {}} + onGenerate={handleGenerate} + isLoading={false} + />, + ); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(handleGenerate).toHaveBeenCalled(); + }); + + it('shows loading state and disables input/button', () => { + render( + {}} + onGenerate={() => {}} + isLoading={true} + />, + ); + const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + const button = screen.getByRole('button'); + + expect(input).toBeDisabled(); + expect(button).toBeDisabled(); + expect(button.querySelector('.loading-spinner')).toBeInTheDocument(); + }); + + it('displays error message when provided', () => { + render( + {}} + onGenerate={() => {}} + isLoading={false} + error="Something went wrong" + />, + ); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/GenerationForm.tsx b/src/components/GenerationForm.tsx new file mode 100644 index 0000000..4f6c004 --- /dev/null +++ b/src/components/GenerationForm.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { IoSend as Send } from 'react-icons/io5'; + +interface GenerationFormProps { + prompt: string; + onPromptChange: (value: string) => void; + onGenerate: () => void; + isLoading: boolean; + error?: string; +} + +export const GenerationForm: React.FC = ({ + prompt, + onPromptChange, + onGenerate, + isLoading, + error, +}) => { + return ( +
+
+

+ Describe your next journey +

+
+ onPromptChange(e.target.value)} + disabled={isLoading} + className="input input-bordered join-item w-full input-md focus:outline-none" + /> + +
+ {error &&

{error}

} +
+
+ ); +}; From a8491aa351edcde89c6713a2e087d1473bc4269c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 09:27:08 +0200 Subject: [PATCH 13/21] feat: extract WorkspaceActions component and add unit tests --- specs/pending/refactor-page-components-v1.md | 2 +- src/app/page.tsx | 42 ++--------- src/components/WorkspaceActions.test.tsx | 79 ++++++++++++++++++++ src/components/WorkspaceActions.tsx | 46 ++++++++++++ 4 files changed, 133 insertions(+), 36 deletions(-) create mode 100644 src/components/WorkspaceActions.test.tsx create mode 100644 src/components/WorkspaceActions.tsx diff --git a/specs/pending/refactor-page-components-v1.md b/specs/pending/refactor-page-components-v1.md index 756b2de..acfb3a0 100644 --- a/specs/pending/refactor-page-components-v1.md +++ b/specs/pending/refactor-page-components-v1.md @@ -62,7 +62,7 @@ This refactoring will be executed incrementally. For each component identified i 1. [x] `EmptyState` 2. [x] `Navbar` -3. [ ] `GenerationForm` +3. [x] `GenerationForm` 4. [ ] `WorkspaceActions` 5. [ ] `TripNavigator` 6. [ ] `TripViewer` diff --git a/src/app/page.tsx b/src/app/page.tsx index 5666035..4ded91c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,9 +4,6 @@ import React, { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import { - FiDownload as Download, - FiUpload as Upload, - FiShare2 as Share2, FiTrash2 as Trash2, FiChevronLeft as ChevronLeft, FiChevronRight as ChevronRight, @@ -20,6 +17,7 @@ import { Logo } from '@/components/Logo'; import { EmptyState } from '@/components/EmptyState'; import { Navbar } from '@/components/Navbar'; import { GenerationForm } from '@/components/GenerationForm'; +import { WorkspaceActions } from '@/components/WorkspaceActions'; export default function Home() { const { @@ -127,38 +125,12 @@ export default function Home() { isLoading={isLoading} error={error} /> -
-
-

- Serverless Workspace -

-
- - -
- -
-
+

{activeTrip ? (
diff --git a/src/components/WorkspaceActions.test.tsx b/src/components/WorkspaceActions.test.tsx new file mode 100644 index 0000000..c71793f --- /dev/null +++ b/src/components/WorkspaceActions.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { WorkspaceActions } from './WorkspaceActions'; + +describe('WorkspaceActions', () => { + it('renders correctly', () => { + render( + {}} + onImport={() => {}} + onShare={() => {}} + />, + ); + expect(screen.getByText(/Serverless Workspace/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Export/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Share Active Link/i })).toBeDisabled(); + }); + + it('enables buttons when totalTrips > 0', () => { + render( + {}} + onImport={() => {}} + onShare={() => {}} + />, + ); + expect(screen.getByRole('button', { name: /Export/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /Share Active Link/i })).not.toBeDisabled(); + }); + + it('calls onExport when Export button is clicked', () => { + const handleExport = vi.fn(); + render( + {}} + onShare={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Export/i })); + expect(handleExport).toHaveBeenCalled(); + }); + + it('calls onShare when Share button is clicked', () => { + const handleShare = vi.fn(); + render( + {}} + onImport={() => {}} + onShare={handleShare} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Share Active Link/i })); + expect(handleShare).toHaveBeenCalled(); + }); + + it('calls onImport when a file is selected', () => { + const handleImport = vi.fn(); + render( + {}} + onImport={handleImport} + onShare={() => {}} + />, + ); + const file = new File(['{}'], 'test.json', { type: 'application/json' }); + const input = screen.getByLabelText(/Import/i); // The label contains the input + + // Note: Since input is hidden, we might need to target it specifically or via label + fireEvent.change(input, { target: { files: [file] } }); + expect(handleImport).toHaveBeenCalled(); + }); +}); diff --git a/src/components/WorkspaceActions.tsx b/src/components/WorkspaceActions.tsx new file mode 100644 index 0000000..cc0b8e9 --- /dev/null +++ b/src/components/WorkspaceActions.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { FiDownload as Download, FiUpload as Upload, FiShare2 as Share2 } from 'react-icons/fi'; + +interface WorkspaceActionsProps { + totalTrips: number; + onExport: () => void; + onImport: (e: React.ChangeEvent) => void; + onShare: () => void; +} + +export const WorkspaceActions: React.FC = ({ + totalTrips, + onExport, + onImport, + onShare, +}) => { + return ( +
+
+

+ Serverless Workspace +

+
+ + +
+ +
+
+ ); +}; From ccd40b0fbe5eb8e144677c93998158751421fb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 09:34:51 +0200 Subject: [PATCH 14/21] feat: extract TripNavigator component and add unit tests --- specs/pending/refactor-page-components-v1.md | 2 +- src/app/page.tsx | 36 +++------ src/components/TripNavigator.test.tsx | 77 ++++++++++++++++++++ src/components/TripNavigator.tsx | 46 ++++++++++++ 4 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 src/components/TripNavigator.test.tsx create mode 100644 src/components/TripNavigator.tsx diff --git a/specs/pending/refactor-page-components-v1.md b/specs/pending/refactor-page-components-v1.md index acfb3a0..55785a8 100644 --- a/specs/pending/refactor-page-components-v1.md +++ b/specs/pending/refactor-page-components-v1.md @@ -63,7 +63,7 @@ This refactoring will be executed incrementally. For each component identified i 1. [x] `EmptyState` 2. [x] `Navbar` 3. [x] `GenerationForm` -4. [ ] `WorkspaceActions` +4. [x] `WorkspaceActions` 5. [ ] `TripNavigator` 6. [ ] `TripViewer` diff --git a/src/app/page.tsx b/src/app/page.tsx index 4ded91c..78ce313 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,11 +3,7 @@ import React, { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; -import { - FiTrash2 as Trash2, - FiChevronLeft as ChevronLeft, - FiChevronRight as ChevronRight, -} from 'react-icons/fi'; +import { FiTrash2 as Trash2 } from 'react-icons/fi'; // Standardized Hook, Service, and Share Utilities import { useTrips } from '@/hooks/useTrips'; @@ -18,6 +14,7 @@ import { EmptyState } from '@/components/EmptyState'; import { Navbar } from '@/components/Navbar'; import { GenerationForm } from '@/components/GenerationForm'; import { WorkspaceActions } from '@/components/WorkspaceActions'; +import { TripNavigator } from '@/components/TripNavigator'; export default function Home() { const { @@ -134,28 +131,13 @@ export default function Home() {
{activeTrip ? (
-
- -
-
{activeTrip.destination}
-
- {activeIndex + 1} / {totalTrips} β€” {activeTrip.createdAt} -
-
- -
+ setActiveIndex(activeIndex + 1)} + onPrev={() => setActiveIndex(activeIndex - 1)} + />
diff --git a/src/components/TripNavigator.test.tsx b/src/components/TripNavigator.test.tsx new file mode 100644 index 0000000..dabcc47 --- /dev/null +++ b/src/components/TripNavigator.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { TripNavigator } from './TripNavigator'; +import { Trip } from '@/types/trip'; + +const mockTrip: Trip = { + id: '1', + destination: 'Paris, France', + content: 'Itinerary content', + createdAt: '2023-10-27', +}; + +describe('TripNavigator', () => { + it('renders trip destination and metadata', () => { + render( + {}} + onPrev={() => {}} + />, + ); + expect(screen.getByText('Paris, France')).toBeInTheDocument(); + expect(screen.getByText(/1 \/ 3/)).toBeInTheDocument(); + expect(screen.getByText(/2023-10-27/)).toBeInTheDocument(); + }); + + it('disables Prev button when at index 0', () => { + render( + {}} + onPrev={() => {}} + />, + ); + expect(screen.getByLabelText(/Previous trip/i)).toBeDisabled(); + expect(screen.getByLabelText(/Next trip/i)).not.toBeDisabled(); + }); + + it('disables Next button when at last index', () => { + render( + {}} + onPrev={() => {}} + />, + ); + expect(screen.getByLabelText(/Next trip/i)).toBeDisabled(); + expect(screen.getByLabelText(/Previous trip/i)).not.toBeDisabled(); + }); + + it('calls onNext and onPrev when buttons are clicked', () => { + const handleNext = vi.fn(); + const handlePrev = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText(/Next trip/i)); + expect(handleNext).toHaveBeenCalled(); + + fireEvent.click(screen.getByLabelText(/Previous trip/i)); + expect(handlePrev).toHaveBeenCalled(); + }); +}); diff --git a/src/components/TripNavigator.tsx b/src/components/TripNavigator.tsx new file mode 100644 index 0000000..172f33b --- /dev/null +++ b/src/components/TripNavigator.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { FiChevronLeft as ChevronLeft, FiChevronRight as ChevronRight } from 'react-icons/fi'; +import { Trip } from '@/types/trip'; + +interface TripNavigatorProps { + activeTrip: Trip; + activeIndex: number; + totalTrips: number; + onNext: () => void; + onPrev: () => void; +} + +export const TripNavigator: React.FC = ({ + activeTrip, + activeIndex, + totalTrips, + onNext, + onPrev, +}) => { + return ( +
+ +
+
{activeTrip.destination}
+
+ {activeIndex + 1} / {totalTrips} β€” {activeTrip.createdAt} +
+
+ +
+ ); +}; From e2330dbff680d68386fb5660f08f27135debabee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 10:04:51 +0200 Subject: [PATCH 15/21] feat: extract TripViewer component and add unit tests --- specs/pending/refactor-page-components-v1.md | 2 +- src/app/page.tsx | 65 +---------------- src/components/TripViewer.test.tsx | 47 +++++++++++++ src/components/TripViewer.tsx | 74 ++++++++++++++++++++ 4 files changed, 124 insertions(+), 64 deletions(-) create mode 100644 src/components/TripViewer.test.tsx create mode 100644 src/components/TripViewer.tsx diff --git a/specs/pending/refactor-page-components-v1.md b/specs/pending/refactor-page-components-v1.md index 55785a8..994fb49 100644 --- a/specs/pending/refactor-page-components-v1.md +++ b/specs/pending/refactor-page-components-v1.md @@ -64,7 +64,7 @@ This refactoring will be executed incrementally. For each component identified i 2. [x] `Navbar` 3. [x] `GenerationForm` 4. [x] `WorkspaceActions` -5. [ ] `TripNavigator` +5. [x] `TripNavigator` 6. [ ] `TripViewer` ## Branching Strategy diff --git a/src/app/page.tsx b/src/app/page.tsx index 78ce313..2bef040 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,20 +1,17 @@ 'use client'; import React, { useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import rehypeRaw from 'rehype-raw'; -import { FiTrash2 as Trash2 } from 'react-icons/fi'; // Standardized Hook, Service, and Share Utilities import { useTrips } from '@/hooks/useTrips'; import { generateItinerary } from '@/services/ai'; import { generateShareUrl } from '@/utils/share'; -import { Logo } from '@/components/Logo'; import { EmptyState } from '@/components/EmptyState'; import { Navbar } from '@/components/Navbar'; import { GenerationForm } from '@/components/GenerationForm'; import { WorkspaceActions } from '@/components/WorkspaceActions'; import { TripNavigator } from '@/components/TripNavigator'; +import { TripViewer } from '@/components/TripViewer'; export default function Home() { const { @@ -138,65 +135,7 @@ export default function Home() { onNext={() => setActiveIndex(activeIndex + 1)} onPrev={() => setActiveIndex(activeIndex - 1)} /> - -
-
- - -
- ( - - {children} - - ), - iframe: ({ src, ...props }) => { - const isGoogleMap = - src?.startsWith('https://www.google.com/maps/embed') || - src?.startsWith('https://www.google.com/maps?') || - src?.startsWith('https://maps.google.com/'); - - if (!isGoogleMap) { - return ( -
- Blocked unsafe iframe: {src} -
- ); - } - - return ( -
- ', + createdAt: '2023-10-27', +}; + +describe('TripViewer', () => { + it('renders markdown content correctly', () => { + render( {}} />); + expect(screen.getByText('Tokyo Trip')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Google Maps link/i })).toBeInTheDocument(); + }); + + it('calls onDelete when delete button is clicked', () => { + const handleDelete = vi.fn(); + render(); + + const deleteBtn = screen.getByTitle(/Remove data entry/i); + fireEvent.click(deleteBtn); + expect(handleDelete).toHaveBeenCalledWith('trip-1'); + }); + + it('renders whitelisted Google Maps iframe', () => { + const { container } = render( {}} />); + const iframe = container.querySelector('iframe'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', 'https://www.google.com/maps/embed?pb=1'); + }); + + it('blocks unsafe iframes', () => { + const unsafeTrip = { + ...mockTrip, + content: '', + }; + const { container } = render( {}} />); + expect(screen.getByText(/Blocked unsafe iframe/i)).toBeInTheDocument(); + expect(container.querySelector('iframe')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/TripViewer.tsx b/src/components/TripViewer.tsx new file mode 100644 index 0000000..f962b88 --- /dev/null +++ b/src/components/TripViewer.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; +import { FiTrash2 as Trash2 } from 'react-icons/fi'; +import { Logo } from '@/components/Logo'; +import { Trip } from '@/types/trip'; + +interface TripViewerProps { + trip: Trip; + onDelete: (id: string) => void; +} + +export const TripViewer: React.FC = ({ trip, onDelete }) => { + return ( +
+
+ + +
+ ( + + {children} + + ), + iframe: ({ src, ...props }) => { + const isGoogleMap = + src?.startsWith('https://www.google.com/maps/embed') || + src?.startsWith('https://www.google.com/maps?') || + src?.startsWith('https://maps.google.com/'); + + if (!isGoogleMap) { + return ( +
+ Blocked unsafe iframe: {src} +
+ ); + } + + return ( +
+