diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a9ccfe2..8436226 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,4 +1,4 @@ -name: E2E Tests +name: Build Test on: push: @@ -7,7 +7,7 @@ on: branches: [ main ] jobs: - e2e-tests: + build-test: runs-on: ubuntu-latest steps: @@ -40,6 +40,12 @@ jobs: - name: Install dependencies run: pnpm install + + - name: Build package + run: pnpm build + + - name: Run publint + run: npx publint --errors-only - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..726e50c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,20 @@ +# Build artifacts +dist/ +node_modules/ +.git/ +.vscode/ +.idea/ + +# Examples directory (not our focus) +examples/ + +# Test files (only focusing on src) +tests/ + +# Coverage reports +coverage/ + +# Lock files +pnpm-lock.yaml +package-lock.json +yarn.lock \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5e124ba --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "arrowParens": "avoid", + "endOfLine": "lf" +} \ No newline at end of file diff --git a/README.md b/README.md index 5b7211a..47553b9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ export default defineConfig(() => { pluginReactRouter({ // Optional: Enable custom server mode customServer: false, + // Optional: Specify server output format + serverOutput: "commonjs", + //Optional: enable experimental support for module federation + federation: false }), pluginReact() ], @@ -60,7 +64,19 @@ pluginReactRouter({ * Enable this when you want to handle server setup manually. * @default false */ - customServer?: boolean + customServer?: boolean, + + /** + * Specify the output format for server-side code. + * Options: "commonjs" | "module" + * @default "module" + */ + serverOutput?: "commonjs" | "module" + /** + * Enable experimental support for module federation + * @default false + */ + federation?: boolean }) ``` diff --git a/examples/cloudflare/package.json b/examples/cloudflare/package.json index 5326569..f3e6a1b 100644 --- a/examples/cloudflare/package.json +++ b/examples/cloudflare/package.json @@ -21,8 +21,8 @@ "@cloudflare/workers-types": "^4.20241112.0", "@react-router/cloudflare": "^7.4.0", "@react-router/dev": "^7.4.0", - "@rsbuild/core": "^1.2.3", - "@rsbuild/plugin-react": "^1.1.0", + "@rsbuild/core": "^1.2.19", + "@rsbuild/plugin-react": "^1.1.1", "@rsbuild/plugin-react-router": "workspace:*", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^20", diff --git a/examples/custom-node-server/package.json b/examples/custom-node-server/package.json index 594a86a..c9a16be 100644 --- a/examples/custom-node-server/package.json +++ b/examples/custom-node-server/package.json @@ -8,7 +8,10 @@ "dev": "RSDOCTOR=false node server.js", "start": "NODE_ENV=production node server.js", "build": "rsbuild build", - "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" }, "keywords": [], "author": "", @@ -23,9 +26,10 @@ "react-router": "^7.4.0" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@react-router/dev": "^7.4.0", - "@rsbuild/core": "^1.2.3", - "@rsbuild/plugin-react": "^1.1.0", + "@rsbuild/core": "^1.2.19", + "@rsbuild/plugin-react": "^1.1.1", "@rsbuild/plugin-react-router": "workspace:*", "@rsdoctor/rspack-plugin": "^0.4.13", "@types/express": "^5.0.0", diff --git a/examples/custom-node-server/playwright.config.ts b/examples/custom-node-server/playwright.config.ts new file mode 100644 index 0000000..d8874f2 --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/server/app.ts b/examples/custom-node-server/server/app.ts deleted file mode 100644 index 477feb4..0000000 --- a/examples/custom-node-server/server/app.ts +++ /dev/null @@ -1,18 +0,0 @@ -import 'react-router'; -import { createRequestHandler } from '@react-router/express'; - -declare module 'react-router' { - interface AppLoadContext { - VALUE_FROM_EXPRESS: string; - } -} - -export const app = createRequestHandler({ - // @ts-expect-error - virtual module provided by React Router at build time - build: () => import('virtual/react-router/server-build'), - getLoadContext() { - return { - VALUE_FROM_EXPRESS: 'Hello from Express', - }; - }, -}); diff --git a/examples/custom-node-server/server/index.ts b/examples/custom-node-server/server/index.ts index 6b0143d..477feb4 100644 --- a/examples/custom-node-server/server/index.ts +++ b/examples/custom-node-server/server/index.ts @@ -7,7 +7,7 @@ declare module 'react-router' { } } -export const index = createRequestHandler({ +export const app = createRequestHandler({ // @ts-expect-error - virtual module provided by React Router at build time build: () => import('virtual/react-router/server-build'), getLoadContext() { diff --git a/examples/custom-node-server/test-results/.last-run.json b/examples/custom-node-server/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/examples/custom-node-server/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/examples/custom-node-server/tests/e2e/README.md b/examples/custom-node-server/tests/e2e/README.md new file mode 100644 index 0000000..3016274 --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/tests/e2e/about.test.ts b/examples/custom-node-server/tests/e2e/about.test.ts new file mode 100644 index 0000000..d59881c --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/tests/e2e/docs.test.ts b/examples/custom-node-server/tests/e2e/docs.test.ts new file mode 100644 index 0000000..b673dde --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/tests/e2e/home.test.ts b/examples/custom-node-server/tests/e2e/home.test.ts new file mode 100644 index 0000000..4f3a679 --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/tests/e2e/navigation.test.ts b/examples/custom-node-server/tests/e2e/navigation.test.ts new file mode 100644 index 0000000..62fe94e --- /dev/null +++ b/examples/custom-node-server/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/custom-node-server/tests/e2e/projects.test.ts b/examples/custom-node-server/tests/e2e/projects.test.ts new file mode 100644 index 0000000..383ee81 --- /dev/null +++ b/examples/custom-node-server/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/examples/default-template/package.json b/examples/default-template/package.json index 4baa616..750799d 100644 --- a/examples/default-template/package.json +++ b/examples/default-template/package.json @@ -23,14 +23,14 @@ "devDependencies": { "@playwright/test": "^1.50.1", "@react-router/dev": "^7.4.0", - "@rsbuild/core": "^1.2.3", - "@rsbuild/plugin-react": "^1.1.0", + "@rsbuild/core": "^1.2.19", + "@rsbuild/plugin-react": "^1.1.1", "@rsbuild/plugin-react-router": "workspace:*", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^20", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", - "react-router-devtools": "^1.1.0", + "react-router-devtools": "^1.1.6", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^5.4.11", diff --git a/examples/epic-stack/app/routes/_auth+/login.tsx b/examples/epic-stack/app/routes/_auth+/login.tsx index d012449..2528a51 100644 --- a/examples/epic-stack/app/routes/_auth+/login.tsx +++ b/examples/epic-stack/app/routes/_auth+/login.tsx @@ -4,10 +4,10 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data, Form, Link, useSearchParams } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' import { z } from 'zod' -import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' -import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' -import { Spacer } from '#app/components/spacer.tsx' -import { StatusButton } from '#app/components/ui/status-button.tsx' +import { GeneralErrorBoundary } from '#app/components/error-boundary' +import { CheckboxField, ErrorList, Field } from '#app/components/forms' +import { Spacer } from '#app/components/spacer' +import { StatusButton } from '#app/components/ui/status-button' import { login, requireAnonymous } from '#app/utils/auth.server.ts' import { ProviderConnectionForm, diff --git a/examples/epic-stack/package.json b/examples/epic-stack/package.json index 04235ee..3d0d2c3 100644 --- a/examples/epic-stack/package.json +++ b/examples/epic-stack/package.json @@ -66,8 +66,8 @@ "@react-router/node": "^7.4.0", "@react-router/remix-routes-option-adapter": "7.4.0", "@remix-run/server-runtime": "2.15.3", - "@rsbuild/core": "1.2.3", - "@rsbuild/plugin-react": "1.1.0", + "@rsbuild/core": "1.2.19", + "@rsbuild/plugin-react": "1.1.1", "@rsbuild/plugin-react-router": "workspace:*", "@sentry/node": "8.54.0", "@sentry/profiling-node": "8.54.0", diff --git a/examples/federation/epic-stack-remote/.github/PULL_REQUEST_TEMPLATE.md b/examples/federation/epic-stack-remote/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..84a2084 --- /dev/null +++ b/examples/federation/epic-stack-remote/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + +## Test Plan + + + +## Checklist + +- [ ] Tests updated +- [ ] Docs updated + +## Screenshots + + diff --git a/examples/federation/epic-stack-remote/.github/workflows/deploy.yml b/examples/federation/epic-stack-remote/.github/workflows/deploy.yml new file mode 100644 index 0000000..e0eac2b --- /dev/null +++ b/examples/federation/epic-stack-remote/.github/workflows/deploy.yml @@ -0,0 +1,174 @@ +name: πŸš€ Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + +jobs: + lint: + name: ⬣ ESLint + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ–Ό Build icons + run: npm run build:icons + + - name: πŸ”¬ Lint + run: npm run lint + + typecheck: + name: Κ¦ TypeScript + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ–Ό Build icons + run: npm run build:icons + + - name: πŸ”Ž Type check + run: npm run typecheck --if-present + + vitest: + name: ⚑ Vitest + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: πŸ–Ό Build icons + run: npm run build:icons + + - name: ⚑ Run vitest + run: npm run test -- --coverage + + playwright: + name: 🎭 Playwright + runs-on: ubuntu-22.04 + timeout-minutes: 60 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ“₯ Install Playwright Browsers + run: npm run test:e2e:install + + - name: πŸ›  Setup Database + run: npx prisma migrate deploy + + - name: 🏦 Cache Database + id: db-cache + uses: actions/cache@v4 + with: + path: prisma/data.db + key: + db-cache-schema_${{ hashFiles('./prisma/schema.prisma') + }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql') + }} + + - name: 🌱 Seed Database + if: steps.db-cache.outputs.cache-hit != 'true' + run: npx prisma migrate reset --force + + - name: πŸ— Build + run: npm run build + + - name: 🎭 Playwright tests + run: npx playwright test + + - name: πŸ“Š Upload report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + deploy: + name: πŸš€ Deploy + runs-on: ubuntu-22.04 + needs: [lint, typecheck, vitest, playwright] + # only build/deploy branches on pushes + if: ${{ github.event_name == 'push' }} + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: '50' + + - name: πŸ‘€ Read app name + uses: SebRollen/toml-action@v1.2.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@1.5 + + - name: πŸš€ Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --app ${{ steps.app_name.outputs.value }}-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: πŸš€ Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/examples/federation/epic-stack-remote/.github/workflows/version.yml b/examples/federation/epic-stack-remote/.github/workflows/version.yml new file mode 100644 index 0000000..1a7515d --- /dev/null +++ b/examples/federation/epic-stack-remote/.github/workflows/version.yml @@ -0,0 +1,54 @@ +name: πŸ”– Version +on: + push: + branches: + - main + - dev + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + version: + name: πŸš€ Update Version + runs-on: ubuntu-22.04 + if: ${{ github.event_name == 'push' }} + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: πŸ”’ Get HEAD commit hash + id: get_head_hash + run: echo "HEAD_HASH=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: πŸ“… Get current date + id: get_date + run: + echo "CURRENT_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + + - name: πŸ“ Update package.json + run: | + jq ' + if .["epic-stack"] then + .["epic-stack"].head = "${{ steps.get_head_hash.outputs.HEAD_HASH }}" | + .["epic-stack"].date = "${{ steps.get_date.outputs.CURRENT_DATE }}" + else + .["epic-stack"] = { + "head": "${{ steps.get_head_hash.outputs.HEAD_HASH }}", + "date": "${{ steps.get_date.outputs.CURRENT_DATE }}" + } + end + ' package.json > temp.json && mv temp.json package.json + + - name: πŸ’Ύ Commit changes + run: | + git config --local user.email "kody@epicweb.dev" + git config --local user.name "kody" + git add package.json + git commit -m "Update epic-stack version [skip ci]" + git push diff --git a/examples/federation/epic-stack-remote/.gitignore b/examples/federation/epic-stack-remote/.gitignore new file mode 100644 index 0000000..6ba5a4f --- /dev/null +++ b/examples/federation/epic-stack-remote/.gitignore @@ -0,0 +1,26 @@ +node_modules +.DS_store + +/build +/server-build +.env +.cache + +/prisma/data.db +/prisma/data.db-journal +/tests/prisma + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/ +/coverage + +/other/cache.db + +# Easy way to create temporary files/folders that won't accidentally be added to git +*.local.* + +# generated files +/app/components/ui/icons +.react-router/ diff --git a/examples/federation/epic-stack-remote/.prettierignore b/examples/federation/epic-stack-remote/.prettierignore new file mode 100644 index 0000000..f022d02 --- /dev/null +++ b/examples/federation/epic-stack-remote/.prettierignore @@ -0,0 +1,15 @@ +node_modules + +/build +/public/build +/server-build +.env + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/*.json +/coverage +/prisma/migrations + +package-lock.json diff --git a/examples/federation/epic-stack-remote/.vscode/extensions.json b/examples/federation/epic-stack-remote/.vscode/extensions.json new file mode 100644 index 0000000..3c0a690 --- /dev/null +++ b/examples/federation/epic-stack-remote/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "prisma.prisma", + "qwtel.sqlite-viewer", + "yoavbls.pretty-ts-errors", + "github.vscode-github-actions" + ] +} diff --git a/examples/federation/epic-stack-remote/.vscode/remix.code-snippets b/examples/federation/epic-stack-remote/.vscode/remix.code-snippets new file mode 100644 index 0000000..39e959f --- /dev/null +++ b/examples/federation/epic-stack-remote/.vscode/remix.code-snippets @@ -0,0 +1,80 @@ +{ + "loader": { + "prefix": "/loader", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export async function loader({ request }: Route.LoaderArgs) {", + " return {}", + "}", + ], + }, + "action": { + "prefix": "/action", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export async function action({ request }: Route.ActionArgs) {", + " return {}", + "}", + ], + }, + "default": { + "prefix": "/default", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {", + " return (", + "
", + "

Unknown Route

", + "
", + " )", + "}", + ], + }, + "headers": { + "prefix": "/headers", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "export const headers: Route.HeadersFunction = ({ loaderHeaders }) => ({", + " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',", + "})", + ], + }, + "links": { + "prefix": "/links", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export const links: Route.LinksFunction = () => {", + " return []", + "}", + ], + }, + "meta": { + "prefix": "/meta", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export const meta: Route.MetaFunction = ({ data }) => [{", + " title: 'Title',", + "}]", + ], + }, + "shouldRevalidate": { + "prefix": "/shouldRevalidate", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type ShouldRevalidateFunctionArgs } from 'react-router'", + "", + "export function shouldRevalidate({ defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {", + " return defaultShouldRevalidate", + "}", + ], + }, +} \ No newline at end of file diff --git a/examples/federation/epic-stack-remote/.vscode/settings.json b/examples/federation/epic-stack-remote/.vscode/settings.json new file mode 100644 index 0000000..9ec5cad --- /dev/null +++ b/examples/federation/epic-stack-remote/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "typescript.preferences.autoImportFileExcludePatterns": [ + "@remix-run/server-runtime", + "express", + "@radix-ui/**", + "@react-email/**", + "node:stream/consumers", + "node:test", + "node:console" + ], + "workbench.editorAssociations": { + "*.db": "sqlite-viewer.view" + } +} diff --git a/examples/federation/epic-stack-remote/CONTRIBUTING.md b/examples/federation/epic-stack-remote/CONTRIBUTING.md new file mode 100644 index 0000000..2a1e49f --- /dev/null +++ b/examples/federation/epic-stack-remote/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contributing + +Thanks for your willingness to contribute! Please make sure to check with me +before doing a bunch of work on something. + +## Project setup + +If you do need to set the project up locally yourself, feel free to follow these +instructions: + +### System Requirements + +- [Node.js](https://nodejs.org/) >= 20.0.0 +- [npm](https://npmjs.com/) >= 8.18.0 +- [git](https://git-scm.com/) >= 2.38.0 + +### Setup steps + +1. Fork repo +2. clone the repo +3. Copy `.env.example` into `.env` +4. Run `npm install && npm run setup -s` to install dependencies and run + validation +5. Create a branch for your PR with `git checkout -b pr/your-branch-name` + +> Tip: Keep your `main` branch pointing at the original repository and make pull +> requests from branches on your fork. To do this, run: +> +> ``` +> git remote add upstream https://github.com/epicweb-dev/epic-stack.git +> git fetch upstream +> git branch --set-upstream-to=upstream/main main +> ``` +> +> This will add the original repository as a "remote" called "upstream," Then +> fetch the git information from that remote, then set your local `main` branch +> to use the upstream main branch whenever you run `git pull`. Then you can make +> all of your pull request branches based on this `main` branch. Whenever you +> want to update your version of `main`, do a regular `git pull`. + +If the setup script doesn't work, you can try to run the commands manually: + +```sh +git clone +cd ./epic-stack + +# copy the .env.example to .env +# everything's mocked out during development so you shouldn't need to +# change any of these values unless you want to hit real environments. +cp .env.example .env + +# Install deps +npm install + +# setup database +prisma migrate reset --force + +# Install playwright browsers +npm run test:e2e:install + +# run build, typecheck, linting +npm run validate +``` + +If that all worked without trouble, you should be able to start development +with: + +```sh +npm run dev +``` + +And open up `http://localhost:3000` and rock! + +## Help Needed + +There's something to be said for custom code and the ability that grants with +regard to tuning it to be exactly what you need. But there's also something to +be said for offloading maintenance onto external dependencies. There are likely +several bits of code in this codebase that could benefit from externalization. +There could even be some things that could be improved by existing libraries. + +Feel free to take any code from within this project and turn it into an open +source library (appropriate attribution is appreciated). Then come back and make +a PR to use your new library. + +NOTE: Actual adoption of your library is not guaranteed. Offloading maintenance +and adaptability is a delicate balance. diff --git a/examples/federation/epic-stack-remote/LICENSE.md b/examples/federation/epic-stack-remote/LICENSE.md new file mode 100644 index 0000000..7c76ba3 --- /dev/null +++ b/examples/federation/epic-stack-remote/LICENSE.md @@ -0,0 +1,18 @@ +Copyright Β© 2023 Kent C. Dodds + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the β€œSoftware”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/examples/federation/epic-stack-remote/README.md b/examples/federation/epic-stack-remote/README.md new file mode 100644 index 0000000..cc8f52f --- /dev/null +++ b/examples/federation/epic-stack-remote/README.md @@ -0,0 +1,54 @@ +
+

The Epic Stack πŸš€

+ + Ditch analysis paralysis and start shipping Epic Web apps. + +

+ This is an opinionated project starter and reference that allows teams to + ship their ideas to production faster and on a more stable foundation based + on the experience of Kent C. Dodds and + contributors. +

+
+ +```sh +npx create-epic-app@latest +``` + +[![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack) + +[The Epic Stack](https://www.epicweb.dev/epic-stack) + +
+ +## Watch Kent's Introduction to The Epic Stack + +[![Epic Stack Talk slide showing Flynn Rider with knives, the text "I've been around and I've got opinions" and Kent speaking in the corner](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/277818553-47158e68-4efc-43ae-a477-9d1670d4217d.png)](https://www.epicweb.dev/talks/the-epic-stack) + +["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack) + +## Docs + +[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs) +(please πŸ™). + +## Support + +- πŸ†˜ Join the + [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions) + and the [KCD Community on Discord](https://kcd.im/discord). +- πŸ’‘ Create an + [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas) + for suggestions. +- πŸ› Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to + report a bug. + +## Branding + +Want to talk about the Epic Stack in a blog post or talk? Great! Here are some +assets you can use in your material: +[EpicWeb.dev/brand](https://epicweb.dev/brand) + +## Thanks + +You rock πŸͺ¨ diff --git a/examples/federation/epic-stack-remote/app/assets/favicons/apple-touch-icon.png b/examples/federation/epic-stack-remote/app/assets/favicons/apple-touch-icon.png new file mode 100644 index 0000000..8bf4632 Binary files /dev/null and b/examples/federation/epic-stack-remote/app/assets/favicons/apple-touch-icon.png differ diff --git a/examples/federation/epic-stack-remote/app/assets/favicons/favicon.svg b/examples/federation/epic-stack-remote/app/assets/favicons/favicon.svg new file mode 100644 index 0000000..72be6f0 --- /dev/null +++ b/examples/federation/epic-stack-remote/app/assets/favicons/favicon.svg @@ -0,0 +1,13 @@ + + + + diff --git a/examples/federation/epic-stack-remote/app/components/error-boundary.tsx b/examples/federation/epic-stack-remote/app/components/error-boundary.tsx new file mode 100644 index 0000000..0aad131 --- /dev/null +++ b/examples/federation/epic-stack-remote/app/components/error-boundary.tsx @@ -0,0 +1,53 @@ +import { captureException } from '@sentry/react' +import { useEffect, type ReactElement } from 'react' +import { + type ErrorResponse, + isRouteErrorResponse, + useParams, + useRouteError, +} from 'react-router' +import { getErrorMessage } from '#app/utils/misc' + +type StatusHandler = (info: { + error: ErrorResponse + params: Record +}) => ReactElement | null + +export function GeneralErrorBoundary({ + defaultStatusHandler = ({ error }) => ( +

+ {error.status} {error.data} +

+ ), + statusHandlers, + unexpectedErrorHandler = (error) =>

{getErrorMessage(error)}

, +}: { + defaultStatusHandler?: StatusHandler + statusHandlers?: Record + unexpectedErrorHandler?: (error: unknown) => ReactElement | null +}) { + const error = useRouteError() + const params = useParams() + const isResponse = isRouteErrorResponse(error) + + if (typeof document !== 'undefined') { + console.error(error) + } + + useEffect(() => { + if (isResponse) return + + captureException(error) + }, [error, isResponse]) + + return ( +
+ {isResponse + ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ + error, + params, + }) + : unexpectedErrorHandler(error)} +
+ ) +} diff --git a/examples/federation/epic-stack-remote/app/components/floating-toolbar.tsx b/examples/federation/epic-stack-remote/app/components/floating-toolbar.tsx new file mode 100644 index 0000000..41b5be0 --- /dev/null +++ b/examples/federation/epic-stack-remote/app/components/floating-toolbar.tsx @@ -0,0 +1,2 @@ +export const floatingToolbarClassName = + 'absolute bottom-3 left-3 right-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-sm md:gap-4 md:pl-7 justify-end' diff --git a/examples/federation/epic-stack-remote/app/components/forms.tsx b/examples/federation/epic-stack-remote/app/components/forms.tsx new file mode 100644 index 0000000..9d3644a --- /dev/null +++ b/examples/federation/epic-stack-remote/app/components/forms.tsx @@ -0,0 +1,202 @@ +import { useInputControl } from '@conform-to/react' +import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp' +import React, { useId } from 'react' +import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx' +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from './ui/input-otp.tsx' +import { Input } from './ui/input.tsx' +import { Label } from './ui/label.tsx' +import { Textarea } from './ui/textarea.tsx' + +export type ListOfErrors = Array | null | undefined + +export function ErrorList({ + id, + errors, +}: { + errors?: ListOfErrors + id?: string +}) { + const errorsToRender = errors?.filter(Boolean) + if (!errorsToRender?.length) return null + return ( +
    + {errorsToRender.map((e) => ( +
  • + {e} +
  • + ))} +
+ ) +} + +export function Field({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: React.InputHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function OTPField({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: Partial + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function TextareaField({ + labelProps, + textareaProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + textareaProps: React.TextareaHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = textareaProps.id ?? textareaProps.name ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+