diff --git a/examples/default-template/.gitignore b/examples/default-template/.gitignore index 9b7c041..e82f4e4 100644 --- a/examples/default-template/.gitignore +++ b/examples/default-template/.gitignore @@ -4,3 +4,4 @@ # React Router /.react-router/ /build/ +test-results diff --git a/examples/default-template/package.json b/examples/default-template/package.json index 400393f..80cbd17 100644 --- a/examples/default-template/package.json +++ b/examples/default-template/package.json @@ -6,7 +6,10 @@ "build": "rsbuild build", "dev": "rsbuild dev", "start": "react-router-serve ./build/server/index.js", - "typecheck": "react-router typegen && tsc" + "typecheck": "react-router typegen && tsc", + "test:e2e": "pnpm run dev & sleep 5 && playwright test", + "test:e2e:debug": "playwright test --debug", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@react-router/express": "^7.1.3", @@ -18,6 +21,7 @@ "react-router": "^7.1.3" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@react-router/dev": "^7.1.3", "@rsbuild/core": "^1.2.3", "@rsbuild/plugin-react": "^1.1.0", diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts new file mode 100644 index 0000000..d8874f2 --- /dev/null +++ b/examples/default-template/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + // Maximum time one test can run for + timeout: 30 * 1000, + expect: { + timeout: 5000 + }, + // Run tests in files in parallel + fullyParallel: false, + // Fail the build on CI if you accidentally left test.only in the source code + forbidOnly: !!process.env.CI, + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Shared settings for all the projects below + use: { + // Base URL to use in actions like `await page.goto('/')` + baseURL: 'http://localhost:3000', + + // Collect trace when retrying the failed test + trace: 'on-first-retry', + + // Take screenshot on test failure + screenshot: 'only-on-failure', + }, + + // Configure only Chrome desktop browser + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ] +}); \ No newline at end of file diff --git a/examples/default-template/rsbuild.config.ts b/examples/default-template/rsbuild.config.ts index 442d926..458a812 100644 --- a/examples/default-template/rsbuild.config.ts +++ b/examples/default-template/rsbuild.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { pluginReactRouter } from '@rsbuild/plugin-react-router'; import 'react-router'; - +import path from 'path'; declare module 'react-router' { interface AppLoadContext { VALUE_FROM_EXPRESS: string; diff --git a/examples/default-template/tests/e2e/README.md b/examples/default-template/tests/e2e/README.md new file mode 100644 index 0000000..3016274 --- /dev/null +++ b/examples/default-template/tests/e2e/README.md @@ -0,0 +1,67 @@ +# End-to-End Tests + +This directory contains end-to-end tests for the React Router default template application using Playwright. + +## Test Structure + +The tests are organized by feature area: + +- `home.test.ts` - Tests for the home page and welcome component +- `about.test.ts` - Tests for the about page +- `docs.test.ts` - Tests for the docs section with nested routes +- `projects.test.ts` - Tests for the projects section with dynamic routes +- `navigation.test.ts` - General navigation flows across the application + +## Running Tests + +You can run the tests using the following npm scripts: + +```bash +# Run all tests +npm run test:e2e + +# Run tests with the Playwright UI +npm run test:e2e:ui + +# Run tests in debug mode +npm run test:e2e:debug +``` + +## Test Configuration + +Test configuration is defined in `playwright.config.ts` in the project root. The configuration: + +- Runs tests in the `tests/e2e` directory +- Tests across multiple browsers (Chrome, Firefox, Safari) +- Tests across desktop and mobile viewports +- Automatically starts the development server before running tests +- Takes screenshots on test failures +- Generates HTML reports + +## Adding New Tests + +To add new tests: + +1. Create a new file in the `tests/e2e` directory with the `.test.ts` extension +2. Import the required Playwright utilities: + ```typescript + import { test, expect } from '@playwright/test'; + ``` +3. Write your tests using the Playwright API +4. Run your tests with `npm run test:e2e` + +## Generating Base Screenshots + +If you need to generate baseline screenshots for visual comparison: + +```bash +npx playwright test --update-snapshots +``` + +## CI Integration + +These tests can be integrated into CI pipelines. The configuration includes special settings for CI environments: + +- More retries on CI +- Forbidding `.only` tests on CI +- Not reusing existing servers on CI \ No newline at end of file diff --git a/examples/default-template/tests/e2e/about.test.ts b/examples/default-template/tests/e2e/about.test.ts new file mode 100644 index 0000000..d59881c --- /dev/null +++ b/examples/default-template/tests/e2e/about.test.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +test.describe('About Page', () => { + test('should display about page content and team members', async ({ page }) => { + // Navigate to about page + await page.goto('/about'); + + // Check page heading + const heading = page.locator('h1:has-text("About This Demo")'); + await expect(heading).toBeVisible(); + + // Check team member cards + const teamCards = page.locator('.card'); + await expect(teamCards).toHaveCount(3); + + // Verify each team member + const expectedMembers = ['React Router', 'Tailwind CSS', 'TypeScript']; + for (let i = 0; i < expectedMembers.length; i++) { + const memberName = expectedMembers[i]; + await expect(teamCards.nth(i).locator('h2')).toContainText(memberName); + } + + // Check that back to home link works + const backLink = page.locator('a:has-text("← Back to Home")'); + await expect(backLink).toBeVisible(); + await backLink.click(); + + // Verify navigation back to home page + await expect(page).toHaveURL(/\/$/); + await expect(page.locator('h1:has-text("Welcome to React Router")')).toBeVisible(); + }); + + test('should have working external links', async ({ page }) => { + // Navigate to about page + await page.goto('/about'); + + // Get all external links + const externalLinks = page.locator('.card a[target="_blank"]'); + + // Verify each link has correct attributes + for (const link of await externalLinks.all()) { + await expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + await expect(link).toHaveText('Learn more →'); + } + }); +}); \ No newline at end of file diff --git a/examples/default-template/tests/e2e/docs.test.ts b/examples/default-template/tests/e2e/docs.test.ts new file mode 100644 index 0000000..b673dde --- /dev/null +++ b/examples/default-template/tests/e2e/docs.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Docs Section', () => { + test('should navigate through docs section with nested routes', async ({ page }) => { + // Navigate to docs index + await page.goto('/docs'); + + // Verify the docs index page is shown + await expect(page).toHaveURL('/docs'); + + // Navigate to getting-started page + await page.goto('/docs/getting-started'); + await expect(page).toHaveURL('/docs/getting-started'); + + // Navigate to advanced page + await page.goto('/docs/advanced'); + await expect(page).toHaveURL('/docs/advanced'); + + // Verify layouts are preserved during navigation + await page.goto('/docs'); + + // Check for the main navigation menu + const mainNav = page.locator('header nav'); + await expect(mainNav).toBeVisible(); + await expect(mainNav.locator('a[href="/docs"]')).toBeVisible(); + }); + + test('should preserve layout when navigating between nested routes', async ({ page }) => { + // Start at docs index + await page.goto('/docs'); + + // Click on the Documentation link in the main nav + const mainNav = page.locator('header nav'); + const docsLink = mainNav.locator('a[href="/docs"]'); + await expect(docsLink).toBeVisible(); + await expect(docsLink).toHaveAttribute('aria-current', 'page'); + + // Navigate to getting-started + await page.goto('/docs/getting-started'); + await expect(page).toHaveURL('/docs/getting-started'); + + // The main navigation should still be visible + await expect(mainNav).toBeVisible(); + await expect(docsLink).toBeVisible(); + + // Navigate to advanced + await page.goto('/docs/advanced'); + await expect(page).toHaveURL('/docs/advanced'); + + // Navigation should still be preserved + await expect(mainNav).toBeVisible(); + await expect(docsLink).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/examples/default-template/tests/e2e/home.test.ts b/examples/default-template/tests/e2e/home.test.ts new file mode 100644 index 0000000..4f3a679 --- /dev/null +++ b/examples/default-template/tests/e2e/home.test.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Home Page', () => { + test('should display welcome message and feature cards', async ({ page }) => { + // Navigate to home page + await page.goto('/'); + + // Check page title + await expect(page).toHaveTitle(/React Router Demo/); + + // Check welcome message + const welcomeHeading = page.locator('h1:has-text("Welcome to React Router")'); + await expect(welcomeHeading).toBeVisible(); + + // Check feature cards (there should be 3) + const featureCards = page.locator('.card h2').filter({ + hasText: /Dynamic Routing|Nested Routes|Route Protection/ + }); + await expect(featureCards).toHaveCount(3); + + // Test hover state on a feature card's parent + const firstFeatureCard = featureCards.first().locator('..').first(); + await firstFeatureCard.hover(); + await expect(firstFeatureCard).toHaveClass(/card.*cursor-pointer/); + + // Test navigation to about page + const aboutPageLinks = page.locator('a[href="/about"]').filter({ hasText: 'View About Page' }); + await expect(aboutPageLinks.first()).toBeVisible(); + await aboutPageLinks.first().click(); + + // Verify navigation to about page + await expect(page).toHaveURL('/about'); + await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible(); + }); + + test('should have working resource links', async ({ page }) => { + // Navigate to home page + await page.goto('/'); + + // Check resource cards + const resourceLinks = page.locator('a.card[target="_blank"]').filter({ + hasText: /React Router Documentation|GitHub Repository|React Router Blog/ + }); + await expect(resourceLinks).toHaveCount(3); + + // Test that links have proper attributes + for (const link of await resourceLinks.all()) { + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', 'noreferrer'); + } + }); +}); \ No newline at end of file diff --git a/examples/default-template/tests/e2e/navigation.test.ts b/examples/default-template/tests/e2e/navigation.test.ts new file mode 100644 index 0000000..62fe94e --- /dev/null +++ b/examples/default-template/tests/e2e/navigation.test.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Navigation Flow', () => { + test('should navigate through all major sections of the app', async ({ page }) => { + // Start at the home page + await page.goto('/'); + await expect(page).toHaveURL('/'); + + // Navigate to about page + await page.goto('/about'); + await expect(page).toHaveURL('/about'); + + // Navigate to docs section + await page.goto('/docs'); + await expect(page).toHaveURL('/docs'); + + // Navigate to projects section + await page.goto('/projects'); + await expect(page).toHaveURL('/projects'); + + // Navigate to a specific project + const projectId = 'react-router'; + await page.goto(`/projects/${projectId}`); + await expect(page).toHaveURL(`/projects/${projectId}`); + }); +}); \ No newline at end of file diff --git a/examples/default-template/tests/e2e/projects.test.ts b/examples/default-template/tests/e2e/projects.test.ts new file mode 100644 index 0000000..383ee81 --- /dev/null +++ b/examples/default-template/tests/e2e/projects.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Projects Section', () => { + test('should display projects listing', async ({ page }) => { + // Navigate to projects index + await page.goto('/projects'); + + // Verify the projects index page URL + await expect(page).toHaveURL('/projects'); + + // Verify the Projects link in nav is active + const projectsLink = page.locator('a.nav-link[href="/projects"]'); + await expect(projectsLink).toBeVisible(); + await expect(projectsLink).toHaveAttribute('aria-current', 'page'); + }); + + test('should navigate to project detail page', async ({ page }) => { + const projectId = 'react-router'; + + // Go directly to the project page + await page.goto(`/projects/${projectId}`); + + // Verify we're on the correct page + await expect(page).toHaveURL(`/projects/${projectId}`); + + // Check project name is displayed + const projectName = page.locator('h1').first(); + await expect(projectName).toBeVisible(); + + // Check edit and settings links in the navigation + const editLink = page.locator(`a[href="/projects/${projectId}/edit"]`); + await expect(editLink).toBeVisible(); + + const settingsLink = page.locator(`a[href="/projects/${projectId}/settings"]`); + await expect(settingsLink).toBeVisible(); + + // Check sections + const sections = page.locator('.card h2').filter({ + hasText: /Progress|Team|Recent Activity/ + }); + await expect(sections).toHaveCount(3); + }); + + test('should navigate to project edit page', async ({ page }) => { + const projectId = 'react-router'; + + // Go to the project detail page + await page.goto(`/projects/${projectId}`); + + // Click the edit link + const editLink = page.locator(`a[href="/projects/${projectId}/edit"]`); + await editLink.click(); + + // Verify we're on the edit page + await expect(page).toHaveURL(`/projects/${projectId}/edit`); + }); + + test('should navigate to project settings page', async ({ page }) => { + const projectId = 'react-router'; + + // Go to the project detail page + await page.goto(`/projects/${projectId}`); + + // Click the settings link + const settingsLink = page.locator(`a[href="/projects/${projectId}/settings"]`); + await settingsLink.click(); + + // Verify we're on the settings page + await expect(page).toHaveURL(`/projects/${projectId}/settings`); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 87f2033..20e4c39 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", "jiti": "^2.4.1", + "playwright": "^1.50.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41ed22d..de5d6b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@types/react-dom': specifier: ^19.0.1 version: 19.0.3(@types/react@19.0.8) + playwright: + specifier: ^1.50.1 + version: 1.50.1 react: specifier: ^19.0.0 version: 19.0.0 @@ -282,6 +285,9 @@ importers: specifier: ^7.1.3 version: 7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) devDependencies: + '@playwright/test': + specifier: ^1.50.1 + version: 1.50.1 '@react-router/dev': specifier: ^7.1.3 version: 7.1.5(@react-router/serve@7.1.5(react-router@7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3))(@types/node@20.17.17)(babel-plugin-macros@3.1.0)(jiti@2.4.2)(lightningcss@1.29.1)(react-router@7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.37.0)(tsx@4.19.2)(typescript@5.7.3)(vite@5.4.14(@types/node@20.17.17)(lightningcss@1.29.1)(terser@5.37.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))(yaml@2.7.0)