diff --git a/.changeset/react-router-manifest-parity.md b/.changeset/react-router-manifest-parity.md new file mode 100644 index 0000000..d66d95d --- /dev/null +++ b/.changeset/react-router-manifest-parity.md @@ -0,0 +1,11 @@ +--- +"rsbuild-plugin-react-router": minor +--- + +Bring Rsbuild plugin behavior closer to React Router's official Vite plugin. + +- Add React Router config resolution + validations/warnings for closer framework parity +- Add split route modules (route chunk entrypoints) including enforce mode validation +- Improve `.client` module stubbing on the server (including `export *` re-exports) +- Improve manifest generation: stable fingerprinted build manifests, bundle-specific server manifests, and optional Subresource Integrity (`future.unstable_subResourceIntegrity`) +- Improve Module Federation support by relying on Rspack `experiments.asyncStartup` (without overriding explicit CommonJS server output) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8436226..bd72bad 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -41,6 +41,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Run unit tests + run: pnpm test + - name: Build package run: pnpm build @@ -51,4 +54,4 @@ jobs: run: npx playwright install --with-deps chromium - name: Run E2E tests - run: pnpm e2e \ No newline at end of file + run: pnpm e2e diff --git a/.gitignore b/.gitignore index 160622e..0fdf6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,18 @@ node_modules tsconfig.tsbuildinfo dist +build +.react-router .npmrc +.unpack-cache/ +.codex/ +task/upstream/ +task/output/ + +# Example build outputs +**/build/ +**/.react-router/ + +# Playwright artifacts +playwright-report/ +test-results/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.nvmrc b/.nvmrc index 8fdd954..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 \ No newline at end of file +22 diff --git a/README.md b/README.md index a10a1d6..284acbc 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ A Rsbuild plugin that provides seamless integration with React Router, supportin - πŸš€ Zero-config setup with sensible defaults - πŸ”„ Automatic route generation from file system - πŸ–₯️ Server-Side Rendering (SSR) support -- πŸ“± Client-side navigation +- πŸ“± Client-side navigation with SPA mode (`ssr: false`) +- πŸ“„ Static prerendering for hybrid static/dynamic sites - πŸ› οΈ TypeScript support out of the box - πŸ”§ Customizable configuration - 🎯 Support for route-level code splitting +- ☁️ Cloudflare Workers deployment support +- πŸ”— Module Federation support (experimental) ## Installation @@ -26,6 +29,18 @@ yarn add rsbuild-plugin-react-router pnpm add rsbuild-plugin-react-router ``` +## Local development + +For the federation examples and Playwright e2e tests, use Node 22 and the +repo-pinned pnpm version: + +```bash +nvm install +nvm use +corepack enable +corepack prepare pnpm@9.15.3 --activate +``` + ## Usage Add the plugin to your `rsbuild.config.ts`: @@ -43,7 +58,7 @@ export default defineConfig(() => { customServer: false, // Optional: Specify server output format serverOutput: "commonjs", - //Optional: enable experimental support for module federation + // Optional: enable experimental support for module federation federation: false }), pluginReact() @@ -78,9 +93,18 @@ pluginReactRouter({ */ federation?: boolean }) + +When Module Federation is enabled, configure your Federation plugin with +`experiments.asyncStartup: true` to avoid requiring entrypoint `import()` hacks. +See the Module Federation examples under `examples/federation`. + +When Module Federation is enabled, some runtimes may expose server build exports +as async getters. The dev server resolves these exports automatically. For +production, use a custom server or an adapter that resolves async exports before +passing the build to React Router's request handler. ``` -2. **React Router Configuration** (in `react-router.config.ts`): +2. **React Router Configuration** (in `react-router.config.*`): ```ts import type { Config } from '@react-router/dev/config'; @@ -91,6 +115,31 @@ export default { */ ssr: true, + /** + * The file name for the server build output. + * @default "index.js" + */ + serverBuildFile: "index.js", + + /** + * The output format for the server build. + * Options: "esm" | "cjs" + * @default "esm" + */ + serverModuleFormat: "esm", + + /** + * Split server bundles by route branch (advanced). + */ + serverBundles: async ({ branch }) => branch[0]?.id ?? "main", + + /** + * Hook called after the build completes. + */ + buildEnd: async ({ buildManifest, reactRouterConfig }) => { + console.log(buildManifest, reactRouterConfig); + }, + /** * Build directory for output files * @default 'build' @@ -108,11 +157,110 @@ export default { * @default '/' */ basename: '/my-app', + + /** + * React Router future flags (optional). + * Example: split client route modules into separate chunks. + */ + future: { + v8_splitRouteModules: true, + }, } satisfies Config; ``` All configuration options are optional and will use sensible defaults if not specified. +### Config File Resolution + +The plugin will look for `react-router.config` with any supported JS/TS extension, in this order: + +- `react-router.config.tsx` +- `react-router.config.ts` +- `react-router.config.mts` +- `react-router.config.jsx` +- `react-router.config.js` +- `react-router.config.mjs` + +If none are found, it falls back to defaults. + +### Framework Mode + +React Router Framework Mode is implemented as a Vite plugin. This Rsbuild +plugin targets Data Mode only and does not support Framework Mode. + +### FAQ + +#### rsbuild-plugin-react-router vs ModernJS + +This plugin is a lightweight adapter to run React Router on Rsbuild. It does +not aim to replace ModernJS or its higher-level framework features. If your +goal is a full framework or advanced microfrontend support, ModernJS may be +a better fit. + +### SPA Mode (`ssr: false`) + +React Router's SPA Mode still requires a build-time server render of the root route to generate a hydratable `index.html` (this is how the official React Router Vite plugin works). + +When `ssr: false`: + +- The plugin builds both `web` and `node` internally. +- It generates `build/client/index.html` by running the server build once (requesting `basename` with the `X-React-Router-SPA-Mode: yes` header). +- It removes `build/server` after generating `index.html`, so the output is deployable as static assets. + +**Important:** In SPA mode, use `clientLoader` instead of `loader` for data loading since there's no server at runtime. + +### Static Prerendering + +For static sites with multiple pages, you can prerender specific routes at build time: + +```ts +// react-router.config.ts +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: false, + prerender: [ + '/', + '/about', + '/docs', + '/docs/getting-started', + '/docs/advanced', + '/projects', + ], +} satisfies Config; +``` + +When `prerender` is specified: + +- Each path in the array is rendered at build time +- Static HTML files are generated for each route (e.g., `/about` β†’ `build/client/about/index.html`) +- The server build is removed after prerendering for static deployment +- Non-prerendered routes fall back to client-side routing + +You can also use `prerender: true` to prerender all static routes automatically. + +`prerender` can also be a function: + +```ts +export default { + ssr: false, + prerender: ({ getStaticPaths }) => + getStaticPaths().filter(path => path !== '/admin'), +} satisfies Config; +``` + +For large sites, you can tune prerender concurrency: + +```ts +export default { + ssr: false, + prerender: { + paths: ['/','/about'], + unstable_concurrency: 4, + }, +} satisfies Config; +``` + ### Default Configuration Values If no configuration is provided, the following defaults will be used: @@ -187,6 +335,7 @@ Route components support the following exports: - `Layout` - Layout component - `clientLoader` - Client-side data loading - `clientAction` - Client-side form actions +- `clientMiddleware` - Client-side middleware - `handle` - Route handle - `links` - Prefetch links - `meta` - Route meta data @@ -195,8 +344,24 @@ Route components support the following exports: #### Server-side Exports - `loader` - Server-side data loading - `action` - Server-side form actions +- `middleware` - Server-side middleware - `headers` - HTTP headers +### Client/Server-only Modules + +- Files ending in `.client.*` are treated as client-only. Their exports are + stubbed to `undefined` in the server build, so they are safe to import from + route components for browser-only behavior. +- Files ending in `.server.*` are server-only. If they are imported by code + compiled for the web environment, the build will fail with a clear error. + Keep `.server` imports in server entrypoints or other server-only code. + +### Asset Prefix + +If you configure `output.assetPrefix` in Rsbuild, the plugin uses that value +for the React Router browser manifest and server build `publicPath` so asset +URLs resolve correctly when serving from a CDN or sub-path. + ## Custom Server Setup The plugin supports two ways to handle server-side rendering: @@ -479,6 +644,51 @@ The plugin automatically: - Handles route-based code splitting - Manages client and server builds +## React Router Framework Mode + +React Router "Framework Mode" wraps Data Mode using a Vite plugin. This Rsbuild plugin currently targets React Router's Data Mode build/runtime model and does not implement the Vite plugin layer (type-safe href, route module splitting, etc.). + +## Examples + +The repository includes several examples demonstrating different use cases: + +| Example | Description | Port | Command | +|---------|-------------|------|---------| +| [default-template](./examples/default-template) | Standard SSR setup with React Router | 3000 | `pnpm dev` | +| [spa-mode](./examples/spa-mode) | Single Page Application (`ssr: false`) | 3001 | `pnpm dev` | +| [prerender](./examples/prerender) | Static prerendering for multiple routes | 3002 | `pnpm dev` | +| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | +| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | +| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | +| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | +| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | +| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | + +Each example has unique ports configured to allow running multiple examples simultaneously. + +### Running Examples + +```bash +# Install dependencies +pnpm install + +# Build the plugin +pnpm build + +# Run any example +cd examples/default-template +pnpm dev +``` + +### Running E2E Tests + +Each example includes Playwright e2e tests: + +```bash +cd examples/default-template +pnpm test:e2e +``` + ## License MIT diff --git a/config/package.json b/config/package.json index 9e09473..7dec387 100644 --- a/config/package.json +++ b/config/package.json @@ -3,9 +3,9 @@ "version": "1.0.1", "private": true, "devDependencies": { - "@rsbuild/core": "1.3.2", - "@rslib/core": "0.5.4", - "@types/node": "^22.10.1", - "typescript": "^5.7.2" + "@rsbuild/core": "1.7.2", + "@rslib/core": "0.19.3", + "@types/node": "^25.0.10", + "typescript": "^5.9.3" } } diff --git a/examples/client-only/README.md b/examples/client-only/README.md new file mode 100644 index 0000000..508661e --- /dev/null +++ b/examples/client-only/README.md @@ -0,0 +1,16 @@ +# Client-only Modules Example + +This example shows how `.client` modules are stubbed on the server build and +loaded on the client after hydration. + +## Run + +```bash +pnpm dev +``` + +## E2E + +```bash +pnpm test:e2e +``` diff --git a/examples/client-only/app/client-value.client.ts b/examples/client-only/app/client-value.client.ts new file mode 100644 index 0000000..0d9dc2f --- /dev/null +++ b/examples/client-only/app/client-value.client.ts @@ -0,0 +1,3 @@ +export function getClientValue(): string { + return `client:${window.location.pathname}`; +} diff --git a/examples/client-only/app/root.tsx b/examples/client-only/app/root.tsx new file mode 100644 index 0000000..ee0c209 --- /dev/null +++ b/examples/client-only/app/root.tsx @@ -0,0 +1,26 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + +
+ Client-only modules +
+
{children}
+ + + + + ); +} + +export default function Root() { + return ; +} diff --git a/examples/client-only/app/routes.ts b/examples/client-only/app/routes.ts new file mode 100644 index 0000000..8eb6d74 --- /dev/null +++ b/examples/client-only/app/routes.ts @@ -0,0 +1,6 @@ +import type { RouteConfigEntry } from '@react-router/dev/routes'; + +export default [ + { path: '/', file: 'routes/index.tsx' }, + { path: 'client-only', file: 'routes/client-only.tsx' }, +] satisfies RouteConfigEntry[]; diff --git a/examples/client-only/app/routes/client-only.tsx b/examples/client-only/app/routes/client-only.tsx new file mode 100644 index 0000000..20aad54 --- /dev/null +++ b/examples/client-only/app/routes/client-only.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router'; +import { getClientValue } from '../client-value.client'; + +export default function ClientOnlyRoute() { + const [value, setValue] = useState('server'); + + useEffect(() => { + if (typeof getClientValue === 'function') { + setValue(getClientValue()); + } + }, []); + + return ( +
+

Client-only route

+

{value}

+

+ Back home +

+
+ ); +} diff --git a/examples/client-only/app/routes/index.tsx b/examples/client-only/app/routes/index.tsx new file mode 100644 index 0000000..49e45d8 --- /dev/null +++ b/examples/client-only/app/routes/index.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router'; + +export default function Index() { + return ( +
+

Welcome

+

+ This example demonstrates .client modules. +

+

+ Go to client-only route +

+
+ ); +} diff --git a/examples/client-only/package.json b/examples/client-only/package.json new file mode 100644 index 0000000..611ed71 --- /dev/null +++ b/examples/client-only/package.json @@ -0,0 +1,32 @@ +{ + "name": "client-only-example", + "private": true, + "type": "module", + "scripts": { + "build": "rsbuild build", + "dev": "NODE_OPTIONS=\"--experimental-vm-modules --experimental-global-webcrypto\" rsbuild dev", + "typecheck": "react-router typegen && tsc", + "test:e2e": "playwright test" + }, + "dependencies": { + "@react-router/express": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "isbot": "^5.1.34", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0" + }, + "devDependencies": { + "@playwright/test": "^1.58.0", + "@react-router/dev": "^7.13.0", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "^1.4.3", + "@types/node": "^25.0.10", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "rsbuild-plugin-react-router": "workspace:*", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/examples/client-only/playwright.config.ts b/examples/client-only/playwright.config.ts new file mode 100644 index 0000000..cf53747 --- /dev/null +++ b/examples/client-only/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:3010', + headless: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm run dev', + url: 'http://localhost:3010', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/examples/client-only/react-router.config.ts b/examples/client-only/react-router.config.ts new file mode 100644 index 0000000..51e8967 --- /dev/null +++ b/examples/client-only/react-router.config.ts @@ -0,0 +1,5 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, +} satisfies Config; diff --git a/examples/client-only/rsbuild.config.ts b/examples/client-only/rsbuild.config.ts new file mode 100644 index 0000000..628c0fe --- /dev/null +++ b/examples/client-only/rsbuild.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { pluginReactRouter } from 'rsbuild-plugin-react-router'; +import 'react-router'; + +export default defineConfig(() => { + return { + plugins: [pluginReactRouter(), pluginReact()], + server: { + port: 3010, + }, + }; +}); diff --git a/examples/client-only/tests/e2e/client-only.test.ts b/examples/client-only/tests/e2e/client-only.test.ts new file mode 100644 index 0000000..d6f8845 --- /dev/null +++ b/examples/client-only/tests/e2e/client-only.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test'; + +test('client-only module hydrates on the client', async ({ page }) => { + await page.goto('/client-only'); + + const value = page.getByTestId('client-value'); + await expect(value).toHaveText(/client:\/client-only/); +}); diff --git a/examples/client-only/tsconfig.json b/examples/client-only/tsconfig.json new file mode 100644 index 0000000..dc391a4 --- /dev/null +++ b/examples/client-only/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/examples/cloudflare/README.md b/examples/cloudflare/README.md index 6a31081..8f992a7 100644 --- a/examples/cloudflare/README.md +++ b/examples/cloudflare/README.md @@ -30,7 +30,7 @@ Start the development server with HMR: npm run dev ``` -Your application will be available at `http://localhost:5173`. +Your application will be available at `http://localhost:3004`. ## Building for Production diff --git a/examples/cloudflare/package.json b/examples/cloudflare/package.json index 9300189..52ce0e5 100644 --- a/examples/cloudflare/package.json +++ b/examples/cloudflare/package.json @@ -5,31 +5,33 @@ "scripts": { "build": "rsbuild build", "deploy": "npm run build && wrangler deploy", - "dev": "rsbuild dev", - "start": "wrangler dev", - "typecheck": "tsc -b" + "dev": "rsbuild dev --port 3004", + "start": "wrangler dev --port 3004", + "typecheck": "tsc -b", + "test:e2e": "playwright test" }, "dependencies": { - "@react-router/node": "^7.4.1", - "@react-router/serve": "^7.4.1", - "isbot": "^5.1.17", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router": "^7.4.1" + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "isbot": "^5.1.34", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20241112.0", - "@react-router/cloudflare": "^7.4.1", - "@react-router/dev": "^7.4.1", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "^1.1.1", - "@tailwindcss/postcss": "^4.0.0", - "@types/node": "^20", - "@types/react": "^19.0.1", - "@types/react-dom": "^19.0.1", + "@cloudflare/workers-types": "^4.20260127.0", + "@playwright/test": "^1.58.0", + "@react-router/cloudflare": "^7.13.0", + "@react-router/dev": "^7.13.0", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "^1.4.3", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^25.0.10", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "rsbuild-plugin-react-router": "workspace:*", - "tailwindcss": "^4.0.0", - "typescript": "^5.7.2", - "wrangler": "^3.106.0" + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "wrangler": "^4.61.0" } } diff --git a/examples/cloudflare/playwright.config.ts b/examples/cloudflare/playwright.config.ts new file mode 100644 index 0000000..6498594 --- /dev/null +++ b/examples/cloudflare/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + // 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, + + // Reporter configuration + reporter: "list", + + // Shared settings for all the projects below + use: { + // Base URL to use in actions like `await page.goto('/')` + baseURL: "http://localhost:3004", + + // Run in headless mode + headless: true, + + // 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"] }, + }, + ], + + // Web server configuration - build first then run wrangler dev for Cloudflare + webServer: { + command: "pnpm run build && pnpm run start", + url: "http://localhost:3004", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/examples/cloudflare/tests/home.test.ts b/examples/cloudflare/tests/home.test.ts new file mode 100644 index 0000000..a736754 --- /dev/null +++ b/examples/cloudflare/tests/home.test.ts @@ -0,0 +1,49 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Cloudflare Home Page", () => { + test("should display React Router welcome page with Cloudflare env message", async ({ + page, + }) => { + // Navigate to home page + await page.goto("/"); + + // Check page title + await expect(page).toHaveTitle(/New React Router App/); + + // Check React Router logo is visible + const logo = page.locator('img[alt="React Router"]'); + await expect(logo.first()).toBeVisible(); + + // Check "What's next?" text + const whatNextText = page.locator('text="What\'s next?"'); + await expect(whatNextText).toBeVisible(); + + // Check resource links + const docsLink = page.locator('a[href="https://reactrouter.com/docs"]'); + await expect(docsLink).toBeVisible(); + await expect(docsLink).toHaveText(/React Router Docs/); + + const discordLink = page.locator('a[href="https://rmx.as/discord"]'); + await expect(discordLink).toBeVisible(); + await expect(discordLink).toHaveText(/Join Discord/); + + // Check Cloudflare environment variable message is displayed + const cloudflareMessage = page.locator("text=Hello from Cloudflare"); + await expect(cloudflareMessage).toBeVisible(); + }); + + test("should have external links with proper attributes", async ({ + page, + }) => { + await page.goto("/"); + + // Check that external links have target="_blank" and rel="noreferrer" + const externalLinks = page.locator('a[target="_blank"]'); + const count = await externalLinks.count(); + expect(count).toBeGreaterThanOrEqual(2); + + for (let i = 0; i < count; i++) { + await expect(externalLinks.nth(i)).toHaveAttribute("rel", "noreferrer"); + } + }); +}); diff --git a/examples/custom-node-server/.gitignore b/examples/custom-node-server/.gitignore index 6c31a39..5597812 100644 --- a/examples/custom-node-server/.gitignore +++ b/examples/custom-node-server/.gitignore @@ -2,3 +2,4 @@ build node_modules .idea +test-results diff --git a/examples/custom-node-server/app/app.css b/examples/custom-node-server/app/app.css index 718406f..b96e5ef 100644 --- a/examples/custom-node-server/app/app.css +++ b/examples/custom-node-server/app/app.css @@ -1,6 +1,6 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; + +@config '../tailwind.config.ts'; html, body { diff --git a/examples/custom-node-server/package.json b/examples/custom-node-server/package.json index e5cd3e7..b86d0e9 100644 --- a/examples/custom-node-server/package.json +++ b/examples/custom-node-server/package.json @@ -6,39 +6,38 @@ "type": "module", "main": "index.js", "scripts": { - "dev": "RSDOCTOR=false node server.js", - "start": "NODE_ENV=production node server.js", + "dev": "RSDOCTOR=false PORT=3003 node server.js", + "start": "NODE_ENV=production PORT=3003 node server.js", "build": "rsbuild build", "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" + "test:e2e": "playwright test" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "@react-router/express": "^7.4.1", - "@react-router/node": "^7.4.1", - "express": "^4.21.2", - "isbot": "^5.1.22", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router": "^7.4.1" + "@react-router/express": "^7.13.0", + "@react-router/node": "^7.13.0", + "express": "^5.2.1", + "isbot": "^5.1.34", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0" }, "devDependencies": { - "@playwright/test": "^1.50.1", - "@react-router/dev": "^7.4.1", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "^1.1.1", - "@rsdoctor/rspack-plugin": "^0.4.13", - "@types/express": "^5.0.0", - "@types/express-serve-static-core": "^5.0.6", - "@types/react": "^19.0.2", - "@types/react-dom": "^19.0.2", + "@playwright/test": "^1.58.0", + "@react-router/dev": "^7.13.0", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "^1.4.3", + "@rsdoctor/rspack-plugin": "^1.5.0", + "@tailwindcss/postcss": "^4.1.18", + "@types/express": "^5.0.6", + "@types/express-serve-static-core": "^5.1.1", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "rsbuild-plugin-react-router": "workspace:*", - "tailwindcss": "^3.4.17", - "typescript": "^5.7.2" + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3" }, "packageManager": "pnpm@9.14.2+sha512.6e2baf77d06b9362294152c851c4f278ede37ab1eba3a55fda317a4a17b209f4dbb973fb250a77abc463a341fcb1f17f17cfa24091c4eb319cda0d9b84278387" } diff --git a/examples/custom-node-server/playwright.config.ts b/examples/custom-node-server/playwright.config.ts index d8874f2..fbeb1bd 100644 --- a/examples/custom-node-server/playwright.config.ts +++ b/examples/custom-node-server/playwright.config.ts @@ -13,15 +13,21 @@ export default defineConfig({ forbidOnly: !!process.env.CI, // Retry on CI only retries: process.env.CI ? 2 : 0, - + + // Reporter configuration + reporter: 'list', + // Shared settings for all the projects below use: { // Base URL to use in actions like `await page.goto('/')` - baseURL: 'http://localhost:3000', - + baseURL: 'http://localhost:3003', + + // Run in headless mode + headless: true, + // Collect trace when retrying the failed test trace: 'on-first-retry', - + // Take screenshot on test failure screenshot: 'only-on-failure', }, @@ -32,5 +38,13 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - ] + ], + + // Web server configuration - starts dev server for custom node server + webServer: { + command: 'pnpm run dev', + url: 'http://localhost:3003', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, }); \ No newline at end of file diff --git a/examples/custom-node-server/postcss.config.cjs b/examples/custom-node-server/postcss.config.cjs index ee5f90b..e564072 100644 --- a/examples/custom-node-server/postcss.config.cjs +++ b/examples/custom-node-server/postcss.config.cjs @@ -1,5 +1,5 @@ module.exports = { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/examples/custom-node-server/server.js b/examples/custom-node-server/server.js index b003c1a..dbd4a57 100644 --- a/examples/custom-node-server/server.js +++ b/examples/custom-node-server/server.js @@ -20,8 +20,18 @@ async function startServer() { app.use(async (req, res, next) => { try { + const tryLoadBundle = async (entryName) => { + try { + return await devServer.environments.node.loadBundle(entryName); + } catch (error) { + if (error instanceof Error && error.message.includes("Can't find entry")) { + return null; + } + throw error; + } + }; const bundle = /** @type {import("./server/index.js")} */ ( - await devServer.environments.node.loadBundle('app') + (await tryLoadBundle('static/js/app')) ?? (await tryLoadBundle('app')) ); await bundle.app(req, res, next); } catch (e) { diff --git a/examples/custom-node-server/tests/e2e/navigation.test.ts b/examples/custom-node-server/tests/e2e/navigation.test.ts index 62fe94e..1627933 100644 --- a/examples/custom-node-server/tests/e2e/navigation.test.ts +++ b/examples/custom-node-server/tests/e2e/navigation.test.ts @@ -19,7 +19,7 @@ test.describe('Navigation Flow', () => { await expect(page).toHaveURL('/projects'); // Navigate to a specific project - const projectId = 'react-router'; + const projectId = '1'; await page.goto(`/projects/${projectId}`); await expect(page).toHaveURL(`/projects/${projectId}`); }); diff --git a/examples/custom-node-server/tests/e2e/projects.test.ts b/examples/custom-node-server/tests/e2e/projects.test.ts index 383ee81..ef91504 100644 --- a/examples/custom-node-server/tests/e2e/projects.test.ts +++ b/examples/custom-node-server/tests/e2e/projects.test.ts @@ -15,8 +15,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project detail page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go directly to the project page await page.goto(`/projects/${projectId}`); @@ -42,8 +42,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project edit page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go to the project detail page await page.goto(`/projects/${projectId}`); @@ -56,8 +56,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project settings page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go to the project detail page await page.goto(`/projects/${projectId}`); diff --git a/examples/default-template/README.md b/examples/default-template/README.md index e0d2066..b03a1ce 100644 --- a/examples/default-template/README.md +++ b/examples/default-template/README.md @@ -32,7 +32,7 @@ Start the development server with HMR: npm run dev ``` -Your application will be available at `http://localhost:5173`. +Your application will be available at `http://localhost:3000`. ## Building for Production diff --git a/examples/default-template/app/root.tsx b/examples/default-template/app/root.tsx index f72e9fa..93c7cfd 100644 --- a/examples/default-template/app/root.tsx +++ b/examples/default-template/app/root.tsx @@ -49,6 +49,7 @@ function Navigation() { { to: '/', label: 'Home' }, { to: '/about', label: 'About' }, { to: '/docs', label: 'Documentation' }, + { to: '/client-features', label: 'Client Features' }, { to: '/projects', label: 'Projects' }, ]; diff --git a/examples/default-template/app/routes.ts b/examples/default-template/app/routes.ts index 8f40db8..c6f525a 100644 --- a/examples/default-template/app/routes.ts +++ b/examples/default-template/app/routes.ts @@ -13,6 +13,9 @@ export default [ // About page route('about', 'routes/about.tsx'), + // Client loader/action example + route('client-features', 'routes/client-features.tsx'), + // Docs section with nested routes ...prefix('docs', [ layout('routes/docs/layout.tsx', [ diff --git a/examples/default-template/app/routes/client-features.tsx b/examples/default-template/app/routes/client-features.tsx new file mode 100644 index 0000000..2367fa6 --- /dev/null +++ b/examples/default-template/app/routes/client-features.tsx @@ -0,0 +1,57 @@ +import { Link, useFetcher, useLoaderData } from 'react-router'; + +import type { Route } from './+types/client-features'; + +export async function loader() { + return { source: 'server' }; +} + +export async function clientLoader() { + return { source: 'client' }; +} + +export async function action() { + return { source: 'server', status: 'submitted' }; +} + +export async function clientAction() { + return { source: 'client', status: 'submitted' }; +} + +export default function ClientFeatures() { + const data = useLoaderData(); + const fetcher = useFetcher(); + + return ( +
+
+

+ Client Features +

+

+ Loader source: {data?.source ?? 'unknown'} +

+ + + +

+ Action source:{' '} + + {fetcher.data?.source ?? 'idle'} + +

+ + ← Back to Home + +
+
+ ); +} diff --git a/examples/default-template/package.json b/examples/default-template/package.json index 3cea0bf..0810f57 100644 --- a/examples/default-template/package.json +++ b/examples/default-template/package.json @@ -8,36 +8,34 @@ "start:esm": "react-router-serve ./build/server/static/js/app.js", "start:cjs": "react-router-serve ./cjs-serve-patch.cjs", "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" + "test:e2e": "playwright test" }, "dependencies": { - "@react-router/express": "^7.4.1", - "@react-router/node": "^7.4.1", - "@react-router/serve": "^7.4.1", - "isbot": "^5.1.17", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router": "^7.4.1" + "@react-router/express": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "isbot": "^5.1.34", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0" }, "devDependencies": { - "@playwright/test": "^1.50.1", - "@react-router/dev": "^7.4.1", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "^1.1.1", - "@tailwindcss/postcss": "^4.0.0", - "@types/node": "^20", - "@types/react": "^19.0.1", - "@types/react-dom": "^19.0.1", - "cross-env": "7.0.3", - "react-router-devtools": "^1.1.6", + "@playwright/test": "^1.58.0", + "@react-router/dev": "^7.13.0", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "^1.4.3", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^25.0.10", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "cross-env": "10.1.0", + "react-router-devtools": "^6.2.0", "rsbuild-plugin-react-router": "workspace:*", - "string-replace-loader": "^3.1.0", - "tailwindcss": "^4.0.0", + "string-replace-loader": "^3.3.0", + "tailwindcss": "^4.1.18", "text-encoder-lite": "^2.0.0", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vite-tsconfig-paths": "^5.1.4" + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.5" } } diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts index d8874f2..66c43d8 100644 --- a/examples/default-template/playwright.config.ts +++ b/examples/default-template/playwright.config.ts @@ -13,15 +13,21 @@ export default defineConfig({ forbidOnly: !!process.env.CI, // Retry on CI only retries: process.env.CI ? 2 : 0, - + + // Reporter configuration + reporter: 'list', + // Shared settings for all the projects below use: { // Base URL to use in actions like `await page.goto('/')` baseURL: 'http://localhost:3000', - + + // Run in headless mode + headless: true, + // Collect trace when retrying the failed test trace: 'on-first-retry', - + // Take screenshot on test failure screenshot: 'only-on-failure', }, @@ -32,5 +38,13 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - ] + ], + + // Web server configuration - starts dev server for SSR mode + webServer: { + command: 'pnpm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, }); \ No newline at end of file diff --git a/examples/default-template/react-router.config.ts b/examples/default-template/react-router.config.ts index 4f9a6ed..9e79e74 100644 --- a/examples/default-template/react-router.config.ts +++ b/examples/default-template/react-router.config.ts @@ -4,4 +4,7 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, + future: { + v8_splitRouteModules: true, + }, } satisfies Config; diff --git a/examples/default-template/tests/e2e/client-features.test.ts b/examples/default-template/tests/e2e/client-features.test.ts new file mode 100644 index 0000000..8afece0 --- /dev/null +++ b/examples/default-template/tests/e2e/client-features.test.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; + +test('client loader runs on client navigation', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Client Features' }).click(); + await expect(page.getByTestId('loader-source')).toHaveText('client'); +}); + +test('client action responds to fetcher submission', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Client Features' }).click(); + const actionSource = page.getByTestId('action-source'); + await expect(actionSource).toHaveText('idle'); + await page.getByRole('button', { name: 'Run client action' }).click(); + await expect(actionSource).toHaveText(/client|server/); +}); diff --git a/examples/default-template/tests/e2e/navigation.test.ts b/examples/default-template/tests/e2e/navigation.test.ts index 62fe94e..1627933 100644 --- a/examples/default-template/tests/e2e/navigation.test.ts +++ b/examples/default-template/tests/e2e/navigation.test.ts @@ -19,7 +19,7 @@ test.describe('Navigation Flow', () => { await expect(page).toHaveURL('/projects'); // Navigate to a specific project - const projectId = 'react-router'; + const projectId = '1'; await page.goto(`/projects/${projectId}`); await expect(page).toHaveURL(`/projects/${projectId}`); }); diff --git a/examples/default-template/tests/e2e/projects.test.ts b/examples/default-template/tests/e2e/projects.test.ts index 383ee81..ef91504 100644 --- a/examples/default-template/tests/e2e/projects.test.ts +++ b/examples/default-template/tests/e2e/projects.test.ts @@ -15,8 +15,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project detail page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go directly to the project page await page.goto(`/projects/${projectId}`); @@ -42,8 +42,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project edit page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go to the project detail page await page.goto(`/projects/${projectId}`); @@ -56,8 +56,8 @@ test.describe('Projects Section', () => { }); test('should navigate to project settings page', async ({ page }) => { - const projectId = 'react-router'; - + const projectId = '1'; + // Go to the project detail page await page.goto(`/projects/${projectId}`); diff --git a/examples/default-template/tests/e2e/ssr-mode.test.ts b/examples/default-template/tests/e2e/ssr-mode.test.ts new file mode 100644 index 0000000..cee0e2a --- /dev/null +++ b/examples/default-template/tests/e2e/ssr-mode.test.ts @@ -0,0 +1,163 @@ +import { test, expect } from '@playwright/test'; +import { existsSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BUILD_DIR = join(__dirname, '../../build'); +const CLIENT_DIR = join(BUILD_DIR, 'client'); +const SERVER_DIR = join(BUILD_DIR, 'server'); + +test.describe('SSR Mode', () => { + test.describe('Server-Side Rendering', () => { + test('should render page content on the server', async ({ page }) => { + // Disable JavaScript to verify SSR content + await page.route('**/*.js', (route) => route.abort()); + + await page.goto('/'); + + // Even without JS, the page should have content rendered by the server + const body = await page.content(); + expect(body).toContain('React Router Demo'); + }); + + test('should hydrate client-side after initial load', async ({ page }) => { + await page.goto('/'); + + // Wait for hydration + await page.waitForFunction(() => { + return ( + typeof (window as any).__reactRouterContext !== 'undefined' && + (window as any).__reactRouterRouteModules !== undefined + ); + }); + + // Should have React Router context + const hasContext = await page.evaluate(() => { + return typeof (window as any).__reactRouterContext !== 'undefined'; + }); + + expect(hasContext).toBe(true); + }); + + test('should support client-side navigation after hydration', async ({ + page, + }) => { + await page.goto('/'); + + // Wait for hydration + await page.waitForFunction(() => { + return (window as any).__reactRouterRouteModules !== undefined; + }); + + // Navigate using client-side routing + const aboutLink = page.locator('a[href="/about"]').first(); + await aboutLink.click(); + + await expect(page).toHaveURL('/about'); + await expect( + page.locator('h1:has-text("About This Demo")') + ).toBeVisible(); + }); + }); + + test.describe('Data Loading', () => { + test('should load data on the server', async ({ page }) => { + const serverResponses: string[] = []; + + page.on('response', (response) => { + if (response.url().includes('/')) { + serverResponses.push(response.url()); + } + }); + + await page.goto('/'); + + // The initial page load should include all data (no separate data fetches) + // because data is loaded on the server + await expect( + page.locator('h1:has-text("Welcome to React Router")') + ).toBeVisible(); + }); + + test('should support loaders in routes', async ({ page }) => { + await page.goto('/'); + + // The home page should have loader data rendered + await expect(page.locator('body')).toContainText('React Router'); + }); + }); + + test.describe('Route Discovery', () => { + test('should support lazy route discovery in SSR mode', async ({ + page, + }) => { + await page.goto('/'); + + // Wait for hydration + await page.waitForFunction(() => { + return (window as any).__reactRouterRouteModules !== undefined; + }); + + // Check route discovery mode + const routeDiscoveryMode = await page.evaluate(() => { + return (window as any).__reactRouterContext?.routeDiscovery?.mode; + }); + + // SSR mode should support lazy route discovery + expect(['lazy', 'initial']).toContain(routeDiscoveryMode); + }); + }); + + test.describe('SEO and Meta', () => { + test('should render meta tags on the server', async ({ page }) => { + await page.goto('/'); + + // Check that meta tags are present in the initial HTML + const title = await page.title(); + expect(title).toBeTruthy(); + + // Should have charset meta tag + const charset = await page.$('meta[charset]'); + expect(charset).toBeTruthy(); + + // Should have viewport meta tag + const viewport = await page.$('meta[name="viewport"]'); + expect(viewport).toBeTruthy(); + }); + }); + + test.describe('Error Handling', () => { + test('should handle 404 routes', async ({ page }) => { + const response = await page.goto('/non-existent-route-12345'); + + // The app should still respond (how it handles 404 depends on route config) + expect(response).toBeTruthy(); + }); + }); + + test.describe('Assets', () => { + test('should preload critical assets', async ({ page }) => { + await page.goto('/'); + + // Check for preload or stylesheet links (indicates SSR is properly configured) + // Rsbuild generates preload/stylesheet links rather than modulepreload + const preloadLinks = await page.$$( + 'link[rel="preload"], link[rel="stylesheet"]' + ); + expect(preloadLinks.length).toBeGreaterThan(0); + }); + + test('should load CSS correctly', async ({ page }) => { + await page.goto('/'); + + // Check for stylesheet links + const stylesheetLinks = await page.$$('link[rel="stylesheet"]'); + expect(stylesheetLinks.length).toBeGreaterThan(0); + + // Verify CSS is applied + const body = page.locator('body'); + await expect(body).toBeVisible(); + }); + }); +}); diff --git a/examples/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts b/examples/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts index 443b49b..51a5242 100644 --- a/examples/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts +++ b/examples/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts @@ -1,10 +1,9 @@ import { invariant } from '@epic-web/invariant' import { faker } from '@faker-js/faker' -import { http } from 'msw' -import { afterEach, expect, test } from 'vitest' +import { HttpResponse, http } from 'msw' +import { afterEach, expect, test } from '@rstest/core' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' -import { connectionSessionStorage } from '#app/utils/connections.server.ts' import { GITHUB_PROVIDER_NAME } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -35,7 +34,7 @@ test('when auth fails, send the user to login with a toast', async () => { consoleError.mockImplementation(() => {}) server.use( http.post('https://github.com/login/oauth/access_token', async () => { - return new Response('error', { status: 400 }) + return HttpResponse.json({ error: 'bad_verification_code' }) }), ) const request = await setupRequest() @@ -219,19 +218,15 @@ async function setupRequest({ const state = faker.string.uuid() url.searchParams.set('state', state) url.searchParams.set('code', code) - const connectionSession = await connectionSessionStorage.getSession() - connectionSession.set('oauth2:state', state) const authSession = await authSessionStorage.getSession() if (sessionId) authSession.set(sessionKey, sessionId) const setSessionCookieHeader = await authSessionStorage.commitSession(authSession) - const setConnectionSessionCookieHeader = - await connectionSessionStorage.commitSession(connectionSession) const request = new Request(url.toString(), { method: 'GET', headers: { cookie: [ - convertSetCookieToCookie(setConnectionSessionCookieHeader), + `github=${new URLSearchParams({ state }).toString()}`, convertSetCookieToCookie(setSessionCookieHeader), ].join('; '), }, diff --git a/examples/epic-stack/app/routes/_marketing+/logos/logos.ts b/examples/epic-stack/app/routes/_marketing+/logos/logos.ts index 7d77e8a..d89cbe4 100644 --- a/examples/epic-stack/app/routes/_marketing+/logos/logos.ts +++ b/examples/epic-stack/app/routes/_marketing+/logos/logos.ts @@ -17,7 +17,7 @@ import sqlite from './sqlite.svg' import tailwind from './tailwind.svg' import testingLibrary from './testing-library.png' import typescript from './typescript.svg' -import vitest from './vitest.svg' +import rstest from './rstest.svg' import zod from './zod.svg' export { default as stars } from './stars.jpg' @@ -122,9 +122,9 @@ export const logos = [ row: 3, }, { - src: vitest, - alt: 'Vitest', - href: 'https://vitest.dev', + src: rstest, + alt: 'Rstest', + href: 'https://rstest.rs', column: 4, row: 4, }, diff --git a/examples/epic-stack/app/routes/_marketing+/logos/rstest.svg b/examples/epic-stack/app/routes/_marketing+/logos/rstest.svg new file mode 100644 index 0000000..fd9daaf --- /dev/null +++ b/examples/epic-stack/app/routes/_marketing+/logos/rstest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/epic-stack/app/routes/users+/$username.test.tsx b/examples/epic-stack/app/routes/users+/$username.test.tsx index eee52fb..60afeac 100644 --- a/examples/epic-stack/app/routes/users+/$username.test.tsx +++ b/examples/epic-stack/app/routes/users+/$username.test.tsx @@ -1,11 +1,8 @@ -/** - * @vitest-environment jsdom - */ import { faker } from '@faker-js/faker' import { render, screen } from '@testing-library/react' import { createRoutesStub } from 'react-router' import setCookieParser from 'set-cookie-parser' -import { test } from 'vitest' +import { test } from '@rstest/core' import { loader as rootLoader } from '#app/root.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' diff --git a/examples/epic-stack/app/styles/tailwind.css b/examples/epic-stack/app/styles/tailwind.css index bc9460f..8e1732a 100644 --- a/examples/epic-stack/app/styles/tailwind.css +++ b/examples/epic-stack/app/styles/tailwind.css @@ -1,6 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; @layer base { :root { diff --git a/examples/epic-stack/app/utils/cache.server.ts b/examples/epic-stack/app/utils/cache.server.ts index 618f5a0..74242d8 100644 --- a/examples/epic-stack/app/utils/cache.server.ts +++ b/examples/epic-stack/app/utils/cache.server.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import { createRequire } from 'node:module' import { cachified as baseCachified, verboseReporter, @@ -11,7 +12,7 @@ import { type CreateReporter, } from '@epic-web/cachified' import { remember } from '@epic-web/remember' -import Database from 'better-sqlite3' +import type Database from 'better-sqlite3' import { LRUCache } from 'lru-cache' import { z } from 'zod' import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts' @@ -20,10 +21,40 @@ import { cachifiedTimingReporter, type Timings } from './timing.server.ts' const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH +const require = createRequire(import.meta.url) +let cachedDatabaseCtor: typeof import('better-sqlite3') | null = null + const cacheDb = remember('cacheDb', createDatabase) -function createDatabase(tryAgain = true): Database.Database { - const db = new Database(CACHE_DATABASE_PATH) +function loadDatabaseCtor() { + if (cachedDatabaseCtor) return cachedDatabaseCtor + try { + const mod = require('better-sqlite3') + cachedDatabaseCtor = mod.default ?? mod + return cachedDatabaseCtor + } catch (error) { + console.warn( + 'SQLite cache disabled (better-sqlite3 not available):', + error instanceof Error ? error.message : error, + ) + return null + } +} + +function createDatabase(tryAgain = true): Database.Database | null { + if (!CACHE_DATABASE_PATH) return null + const DatabaseCtor = loadDatabaseCtor() + if (!DatabaseCtor) return null + let db: Database.Database + try { + db = new DatabaseCtor(CACHE_DATABASE_PATH) + } catch (error) { + console.warn( + 'SQLite cache disabled (failed to open database):', + error instanceof Error ? error.message : error, + ) + return null + } const { currentIsPrimary } = getInstanceInfoSync() if (!currentIsPrimary) return db @@ -84,6 +115,7 @@ const cacheQueryResultSchema = z.object({ export const cache: CachifiedCache = { name: 'SQLite cache', get(key) { + if (!cacheDb) return lruCache.get(key) ?? null const result = cacheDb .prepare('SELECT value, metadata FROM cache WHERE key = ?') .get(key) @@ -100,6 +132,10 @@ export const cache: CachifiedCache = { return { metadata, value } }, async set(key, entry) { + if (!cacheDb) { + lruCache.set(key, entry) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb @@ -127,6 +163,10 @@ export const cache: CachifiedCache = { } }, async delete(key) { + if (!cacheDb) { + lruCache.delete(key) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key) @@ -149,9 +189,11 @@ export const cache: CachifiedCache = { export async function getAllCacheKeys(limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache LIMIT ?') - .all(limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache LIMIT ?') + .all(limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()], } } @@ -159,9 +201,11 @@ export async function getAllCacheKeys(limit: number) { export async function searchCacheKeys(search: string, limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') - .all(`%${search}%`, limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') + .all(`%${search}%`, limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()].filter((key) => key.includes(search)), } } diff --git a/examples/epic-stack/app/utils/headers.server.test.ts b/examples/epic-stack/app/utils/headers.server.test.ts index 42b5a1a..b370d37 100644 --- a/examples/epic-stack/app/utils/headers.server.test.ts +++ b/examples/epic-stack/app/utils/headers.server.test.ts @@ -1,5 +1,5 @@ import { format, parse } from '@tusbar/cache-control' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { getConservativeCacheControl } from './headers.server.ts' test('works for basic usecase', () => { @@ -22,9 +22,9 @@ test('retains boolean directive', () => { getConservativeCacheControl('private', 'no-cache,no-store'), ) - expect(result.private).toEqual(true) - expect(result.noCache).toEqual(true) - expect(result.noStore).toEqual(true) + expect(result.private).toBe(true) + expect(result.noCache).toBe(true) + expect(result.noStore).toBe(true) }) test('gets smallest number directive', () => { const result = parse( @@ -34,6 +34,6 @@ test('gets smallest number directive', () => { ), ) - expect(result.maxAge).toEqual(10) - expect(result.sharedMaxAge).toEqual(300) + expect(result.maxAge).toBe(10) + expect(result.sharedMaxAge).toBe(300) }) diff --git a/examples/epic-stack/app/utils/litefs.server.ts b/examples/epic-stack/app/utils/litefs.server.ts index 0565a5b..1ead2b2 100644 --- a/examples/epic-stack/app/utils/litefs.server.ts +++ b/examples/epic-stack/app/utils/litefs.server.ts @@ -7,4 +7,4 @@ export { getInternalInstanceDomain, getInstanceInfoSync, } from 'litefs-js' -export { ensurePrimary, ensureInstance } from 'litefs-js/remix.js' +export { ensurePrimary, ensureInstance } from 'litefs-js/remix' diff --git a/examples/epic-stack/app/utils/misc.error-message.test.ts b/examples/epic-stack/app/utils/misc.error-message.test.ts index 1fe4d7a..49b6c95 100644 --- a/examples/epic-stack/app/utils/misc.error-message.test.ts +++ b/examples/epic-stack/app/utils/misc.error-message.test.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { consoleError } from '#tests/setup/setup-test-env.ts' import { getErrorMessage } from './misc.tsx' diff --git a/examples/epic-stack/app/utils/misc.use-double-check.test.tsx b/examples/epic-stack/app/utils/misc.use-double-check.test.tsx index 4adfa59..9d9f72c 100644 --- a/examples/epic-stack/app/utils/misc.use-double-check.test.tsx +++ b/examples/epic-stack/app/utils/misc.use-double-check.test.tsx @@ -1,10 +1,7 @@ -/** - * @vitest-environment jsdom - */ import { render, screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { useState } from 'react' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { useDoubleCheck } from './misc.tsx' function TestComponent() { diff --git a/examples/epic-stack/app/utils/providers/github.server.ts b/examples/epic-stack/app/utils/providers/github.server.ts index 054719b..290ca98 100644 --- a/examples/epic-stack/app/utils/providers/github.server.ts +++ b/examples/epic-stack/app/utils/providers/github.server.ts @@ -26,24 +26,48 @@ const shouldMock = export class GitHubProvider implements AuthProvider { getAuthStrategy() { + const redirectURI = process.env.GITHUB_REDIRECT_URI + ? new URL(process.env.GITHUB_REDIRECT_URI) + : new URL('/auth/github/callback', process.env.APP_BASE_URL ?? 'http://localhost') + return new GitHubStrategy( { - clientID: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: '/auth/github/callback', + clientId: process.env.GITHUB_CLIENT_ID ?? 'MOCK_GITHUB_CLIENT_ID', + clientSecret: process.env.GITHUB_CLIENT_SECRET ?? 'MOCK_GITHUB_CLIENT_SECRET', + redirectURI, + scopes: ['user:email'], }, - async ({ profile }) => { - const email = profile.emails[0]?.value.trim().toLowerCase() + async ({ tokens }) => { + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `token ${tokens.accessToken()}`, + 'X-GitHub-Api-Version': '2022-11-28', + } + const [userResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { headers }), + fetch('https://api.github.com/user/emails', { headers }), + ]) + const userProfile = await userResponse.json() + const emails: Array<{ + email: string + primary?: boolean + verified?: boolean + }> = await emailsResponse.json() + const primaryEmail = + emails.find((email) => email.primary && email.verified) ?? + emails.find((email) => email.verified) ?? + emails[0] + const email = primaryEmail?.email?.trim().toLowerCase() if (!email) { throw new Error('Email not found') } - const username = profile.displayName - const imageUrl = profile.photos[0]?.value + const username = userProfile.login ?? userProfile.name ?? email + const imageUrl = userProfile.avatar_url return { email, - id: profile.id, + id: String(userProfile.id), username, - name: profile.name.givenName, + name: userProfile.name ?? username, imageUrl, } }, diff --git a/examples/epic-stack/docs/decisions/031-imports.md b/examples/epic-stack/docs/decisions/031-imports.md index ebd8f47..cac705d 100644 --- a/examples/epic-stack/docs/decisions/031-imports.md +++ b/examples/epic-stack/docs/decisions/031-imports.md @@ -34,7 +34,7 @@ autocomplete with TypeScript configure both, then you can get the best of both worlds! By using the `"imports"` field, you don't have to do any special configuration -for `vitest` or `eslint` to be able to resolve imports. They just resolve them +for `rstest` or `eslint` to be able to resolve imports. They just resolve them using the standard. And by using the `tsconfig.json` `paths` field configured in the same way as the diff --git a/examples/epic-stack/docs/features.md b/examples/epic-stack/docs/features.md index ab070ed..9247c91 100644 --- a/examples/epic-stack/docs/features.md +++ b/examples/epic-stack/docs/features.md @@ -30,7 +30,7 @@ Here are a few things you get today: [Radix UI](https://www.radix-ui.com/) - End-to-end testing with [Playwright](https://playwright.dev/) - Local third party request mocking with [MSW](https://mswjs.io/) -- Unit testing with [Vitest](https://vitest.dev/) and +- Unit testing with [Rstest](https://rstest.rs/) and [Testing Library](https://testing-library.com/) with pre-configured Test Database - Code formatting with [Prettier](https://prettier.io/) diff --git a/examples/epic-stack/docs/testing.md b/examples/epic-stack/docs/testing.md index 8a0b630..41f3e86 100644 --- a/examples/epic-stack/docs/testing.md +++ b/examples/epic-stack/docs/testing.md @@ -22,9 +22,9 @@ test('my test', async ({ page, login }) => { We also auto-delete the user at the end of your test. That way, we can keep your local db clean and keep your tests isolated from one another. -## Vitest +## Rstest -For lower level tests of utilities and individual components, we use `vitest`. +For lower level tests of utilities and individual components, we use `rstest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). diff --git a/examples/epic-stack/package.json b/examples/epic-stack/package.json index a6a099e..2806ded 100644 --- a/examples/epic-stack/package.json +++ b/examples/epic-stack/package.json @@ -15,22 +15,22 @@ "build:remix": "rsbuild build", "build:server": "tsx ./other/build-server.ts", "predev": "npm run build:icons --silent", - "dev": "cross-env NODE_ENV=development MOCKS=true tsx ./server/dev-server.js", + "dev": "cross-env NODE_ENV=development MOCKS=true PORT=3005 tsx ./server/dev-server.js", "prisma:studio": "prisma studio", "format": "prettier --write .", "lint": "eslint .", "setup": "prisma generate && prisma migrate reset && playwright install && pnpm run build", "start": "cross-env NODE_ENV=production node .", "start:mocks": "cross-env NODE_ENV=production MOCKS=true tsx .", - "test": "vitest", - "coverage": "vitest run --coverage", - "test:e2e": "npm run test:e2e:dev --silent", - "test:e2e:dev": "playwright test --ui", + "test": "rstest", + "coverage": "rstest run --coverage", + "test:e2e": "playwright test", + "test:e2e:dev": "playwright test", "pretest:e2e:run": "npm run build", "test:e2e:run": "cross-env CI=true playwright test", "test:e2e:install": "npx playwright install --with-deps chromium", "typecheck": "react-router typegen && tsc", - "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run" + "validate": "run-p test lint typecheck test:e2e:run" }, "prettier": "@epic-web/config/prettier", "eslintIgnore": [ @@ -41,126 +41,128 @@ "/server-build" ], "dependencies": { - "@conform-to/react": "1.2.2", - "@conform-to/zod": "1.2.2", - "@epic-web/cachified": "5.2.0", - "@epic-web/client-hints": "1.3.5", + "@conform-to/react": "1.16.0", + "@conform-to/zod": "1.16.0", + "@epic-web/cachified": "5.6.1", + "@epic-web/client-hints": "1.3.8", "@epic-web/invariant": "1.0.0", "@epic-web/remember": "1.1.0", - "@epic-web/totp": "2.1.1", - "@mjackson/form-data-parser": "0.7.0", + "@epic-web/totp": "4.0.1", + "@mjackson/form-data-parser": "0.9.1", "@nasa-gcn/remix-seo": "2.0.1", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", - "@paralleldrive/cuid2": "2.2.2", - "@prisma/client": "6.3.1", - "@prisma/instrumentation": "6.3.1", - "@radix-ui/react-checkbox": "1.1.3", - "@radix-ui/react-dropdown-menu": "2.1.5", - "@radix-ui/react-label": "2.1.1", - "@radix-ui/react-slot": "1.1.1", - "@radix-ui/react-toast": "1.2.5", - "@radix-ui/react-tooltip": "1.1.7", - "@react-email/components": "0.0.32", - "@react-router/express": "7.4.0", - "@react-router/node": "^7.4.1", - "@react-router/remix-routes-option-adapter": "7.4.0", - "@remix-run/server-runtime": "2.15.3", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "1.1.1", - "@sentry/node": "8.54.0", - "@sentry/profiling-node": "8.54.0", - "@sentry/react": "8.54.0", - "@tusbar/cache-control": "1.0.2", + "@paralleldrive/cuid2": "3.3.0", + "@prisma/client": "6.0.0", + "@prisma/instrumentation": "7.3.0", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-tooltip": "1.2.8", + "@react-email/components": "1.0.6", + "@react-router/express": "7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/remix-routes-option-adapter": "7.13.0", + "@remix-run/server-runtime": "2.17.4", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "1.4.3", + "@sentry/node": "10.37.0", + "@sentry/profiling-node": "10.37.0", + "@sentry/react": "10.37.0", + "@tusbar/cache-control": "2.0.0", "address": "2.0.3", - "bcryptjs": "2.4.3", - "better-sqlite3": "11.8.1", - "chalk": "5.4.1", + "bcryptjs": "3.0.3", + "better-sqlite3": "12.6.2", + "chalk": "5.6.2", "class-variance-authority": "0.7.1", - "close-with-grace": "2.2.0", + "close-with-grace": "2.4.0", "clsx": "2.1.1", - "compression": "1.7.5", - "cookie": "1.0.2", - "cross-env": "7.0.3", + "compression": "1.8.1", + "cookie": "1.1.1", + "cross-env": "10.1.0", "date-fns": "4.1.0", - "dotenv": "16.4.7", - "execa": "9.5.2", - "express": "4.21.2", - "express-rate-limit": "7.5.0", + "dotenv": "17.2.3", + "execa": "9.6.1", + "express": "5.2.1", + "express-rate-limit": "8.2.1", "get-port": "7.1.0", - "glob": "11.0.1", - "helmet": "8.0.0", + "glob": "13.0.0", + "helmet": "8.1.0", "input-otp": "1.4.2", "intl-parse-accept-language": "1.0.0", - "isbot": "5.1.22", - "litefs-js": "1.1.2", - "lru-cache": "11.0.2", - "morgan": "1.10.0", - "prisma": "6.3.1", + "isbot": "5.1.34", + "litefs-js": "2.0.2", + "lru-cache": "11.2.5", + "morgan": "1.10.1", + "prisma": "6.0.0", "qrcode": "1.5.4", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-router": "^7.4.1", - "remix-auth": "3.7.0", - "remix-auth-github": "1.7.0", - "remix-utils": "8.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0", + "remix-auth": "4.2.0", + "remix-auth-github": "3.0.2", + "remix-utils": "9.0.0", "rsbuild-plugin-react-router": "workspace:*", - "set-cookie-parser": "2.7.1", - "sonner": "1.7.4", + "set-cookie-parser": "3.0.1", + "sonner": "2.0.7", "source-map-support": "0.5.21", "spin-delay": "2.0.1", - "tailwind-merge": "2.6.0", - "tailwindcss": "3.4.17", + "tailwind-merge": "3.4.0", + "tailwindcss": "4.1.18", "tailwindcss-animate": "1.0.7", - "tailwindcss-radix": "3.0.5", + "tailwindcss-radix": "4.0.2", "vite-env-only": "3.0.3", - "zod": "3.24.1" + "zod": "3.25.76" }, "devDependencies": { - "@epic-web/config": "1.16.5", - "@faker-js/faker": "9.4.0", - "@playwright/test": "1.50.1", - "@react-router/dev": "^7.4.1", - "@sentry/vite-plugin": "3.1.2", - "@sly-cli/sly": "1.14.0", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.2.0", + "@epic-web/config": "1.21.3", + "@faker-js/faker": "10.2.0", + "@playwright/test": "1.58.0", + "@react-router/dev": "^7.13.0", + "@sentry/vite-plugin": "4.8.0", + "@sly-cli/sly": "2.1.1", + "@tailwindcss/nesting": "0.0.0-insiders.565cd3e", + "@tailwindcss/postcss": "^4.1.18", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@total-typescript/ts-reset": "0.6.1", - "@types/bcryptjs": "2.4.6", - "@types/better-sqlite3": "7.6.12", - "@types/compression": "1.7.5", + "@types/bcryptjs": "3.0.0", + "@types/better-sqlite3": "7.6.13", + "@types/compression": "1.8.1", "@types/eslint": "9.6.1", - "@types/express": "5.0.0", + "@types/express": "^5.0.6", "@types/fs-extra": "11.0.4", - "@types/glob": "8.1.0", - "@types/morgan": "1.9.9", - "@types/node": "22.13.1", - "@types/qrcode": "1.5.5", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", + "@types/glob": "9.0.0", + "@types/morgan": "1.9.10", + "@types/node": "25.0.10", + "@types/qrcode": "1.5.6", + "@types/react": "19.2.10", + "@types/react-dom": "19.2.3", "@types/set-cookie-parser": "2.4.10", "@types/source-map-support": "0.5.10", - "@vitejs/plugin-react": "4.3.4", - "@vitest/coverage-v8": "3.0.5", - "autoprefixer": "10.4.20", + "@rstest/core": "0.8.1", + "@rstest/coverage-istanbul": "0.2.0", + "@vitejs/plugin-react": "5.1.2", + "autoprefixer": "10.4.23", "enforce-unique": "1.3.0", - "esbuild": "0.24.2", - "eslint": "9.19.0", - "fs-extra": "11.3.0", - "jsdom": "25.0.1", - "msw": "2.7.0", - "node-html-parser": "7.0.1", + "esbuild": "0.27.2", + "eslint": "9.39.2", + "fs-extra": "11.3.3", + "jsdom": "27.4.0", + "msw": "2.12.7", + "node-html-parser": "7.0.2", "npm-run-all": "4.1.5", - "prettier": "3.4.2", - "prettier-plugin-sql": "0.18.1", - "prettier-plugin-tailwindcss": "0.6.11", - "remix-flat-routes": "0.8.4", - "tsx": "4.19.2", - "typescript": "5.7.3", - "vite": "6.0.11", - "vitest": "3.0.5" + "prettier": "3.8.1", + "prettier-plugin-sql": "0.19.2", + "prettier-plugin-tailwindcss": "0.7.2", + "remix-flat-routes": "0.8.5", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vite": "7.3.1" }, "engines": { "node": "22" diff --git a/examples/epic-stack/playwright.config.ts b/examples/epic-stack/playwright.config.ts index e5d832d..2ddaff9 100644 --- a/examples/epic-stack/playwright.config.ts +++ b/examples/epic-stack/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from '@playwright/test' import 'dotenv/config' -const PORT = process.env.PORT || '3000' +const PORT = process.env.PORT || '3005' export default defineConfig({ testDir: './tests/e2e', @@ -13,9 +13,10 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: 'html', + reporter: 'list', use: { baseURL: `http://localhost:${PORT}/`, + headless: true, trace: 'on-first-retry', }, @@ -29,14 +30,9 @@ export default defineConfig({ ], webServer: { - command: process.env.CI ? 'npm run start:mocks' : 'npm run dev', - port: Number(PORT), - reuseExistingServer: true, - stdout: 'pipe', - stderr: 'pipe', - env: { - PORT, - NODE_ENV: 'test', - }, + command: 'pnpm run dev', + url: 'http://localhost:3005', + reuseExistingServer: !process.env.CI, + timeout: 120000, }, }) diff --git a/examples/epic-stack/postcss.config.js b/examples/epic-stack/postcss.config.js index 5ebad51..9443b5c 100644 --- a/examples/epic-stack/postcss.config.js +++ b/examples/epic-stack/postcss.config.js @@ -1,7 +1,7 @@ export default { plugins: { - 'tailwindcss/nesting': {}, - tailwindcss: {}, + '@tailwindcss/nesting': {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/examples/epic-stack/rstest.config.ts b/examples/epic-stack/rstest.config.ts new file mode 100644 index 0000000..a286b60 --- /dev/null +++ b/examples/epic-stack/rstest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@rstest/core'; +export default defineConfig({ + tools: { + swc: { + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + testEnvironment: 'jsdom', + globalSetup: ['./tests/setup/global-setup.ts'], + include: [ + 'app/**/*.{test,spec}.?(c|m)[jt]s?(x)', + 'tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', + ], + exclude: [ + 'tests/e2e/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/server-build/**', + '**/.react-router/**', + ], + setupFiles: ['./tests/setup/setup-test-env.ts'], +}); diff --git a/examples/epic-stack/tests/e2e/note-images.test.ts b/examples/epic-stack/tests/e2e/note-images.test.ts index 5b1addc..36b6d89 100644 --- a/examples/epic-stack/tests/e2e/note-images.test.ts +++ b/examples/epic-stack/tests/e2e/note-images.test.ts @@ -109,8 +109,8 @@ test('Users can delete note image', async ({ page, login }) => { await page.getByRole('button', { name: 'remove image' }).click() await page.getByRole('button', { name: 'submit' }).click() await expect(page).toHaveURL(`/users/${user.username}/notes/${note.id}`) - const countAfter = await images.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = images + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/epic-stack/tests/e2e/notes.test.ts b/examples/epic-stack/tests/e2e/notes.test.ts index 23b9259..4e15a3b 100644 --- a/examples/epic-stack/tests/e2e/notes.test.ts +++ b/examples/epic-stack/tests/e2e/notes.test.ts @@ -62,8 +62,8 @@ test('Users can delete notes', async ({ page, login }) => { page.getByText('Your note has been deleted.', { exact: true }), ).toBeVisible() await expect(page).toHaveURL(`/users/${user.username}/notes`) - const countAfter = await noteLinks.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = noteLinks + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/epic-stack/tests/e2e/onboarding.test.ts b/examples/epic-stack/tests/e2e/onboarding.test.ts index 92064e3..a967676 100644 --- a/examples/epic-stack/tests/e2e/onboarding.test.ts +++ b/examples/epic-stack/tests/e2e/onboarding.test.ts @@ -301,7 +301,7 @@ test('shows help texts on entering invalid details on onboarding page after GitH }), ) // we are truncating the user's input - expect((await usernameInput.inputValue()).length).toBe(USERNAME_MAX_LENGTH) + expect((await usernameInput.inputValue())).toHaveLength(USERNAME_MAX_LENGTH) await createAccountButton.click() await expect(page.getByText(/username is too long/i)).not.toBeVisible() diff --git a/examples/epic-stack/tests/e2e/settings-profile.test.ts b/examples/epic-stack/tests/e2e/settings-profile.test.ts index 7af0a08..4590b3a 100644 --- a/examples/epic-stack/tests/e2e/settings-profile.test.ts +++ b/examples/epic-stack/tests/e2e/settings-profile.test.ts @@ -45,7 +45,7 @@ test('Users can update their password', async ({ page, login }) => { expect( await verifyUserPassword({ username }, oldPassword), 'Old password still works', - ).toEqual(null) + ).toBeNull() expect( await verifyUserPassword({ username }, newPassword), 'New password does not work', @@ -56,9 +56,9 @@ test('Users can update their profile photo', async ({ page, login }) => { const user = await login() await page.goto('/settings/profile') - const beforeSrc = await page + const beforeSrc = page .getByRole('img', { name: user.name ?? user.username }) - .getAttribute('src') + await page.getByRole('link', { name: /change profile photo/i }).click() @@ -79,7 +79,7 @@ test('Users can update their profile photo', async ({ page, login }) => { .getByRole('img', { name: user.name ?? user.username }) .getAttribute('src') - expect(beforeSrc).not.toEqual(afterSrc) + await expect(beforeSrc).not.toHaveAttribute('src', afterSrc) }) test('Users can change their email address', async ({ page, login }) => { diff --git a/examples/epic-stack/tests/mocks/github.ts b/examples/epic-stack/tests/mocks/github.ts index 6290a8f..acd304c 100644 --- a/examples/epic-stack/tests/mocks/github.ts +++ b/examples/epic-stack/tests/mocks/github.ts @@ -14,7 +14,7 @@ const githubUserFixturePath = path.join( '..', 'fixtures', 'github', - `users.${process.env.VITEST_POOL_ID || 0}.local.json`, + `users.${process.env.RSTEST_WORKER_ID || 0}.local.json`, ), ) @@ -128,6 +128,7 @@ async function getUser(request: Request) { } const passthroughGitHub = + process.env.GITHUB_CLIENT_ID && !process.env.GITHUB_CLIENT_ID.startsWith('MOCK_') && process.env.NODE_ENV !== 'test' @@ -145,13 +146,10 @@ export const handlers: Array = [ user = await insertGitHubUser(code) } - return new Response( - new URLSearchParams({ - access_token: user.accessToken, - token_type: '__MOCK_TOKEN_TYPE__', - }).toString(), - { headers: { 'content-type': 'application/x-www-form-urlencoded' } }, - ) + return json({ + access_token: user.accessToken, + token_type: '__MOCK_TOKEN_TYPE__', + }) }, ), http.get('https://api.github.com/user/emails', async ({ request }) => { diff --git a/examples/epic-stack/tests/setup/custom-matchers.ts b/examples/epic-stack/tests/setup/custom-matchers.ts index 6e09a20..e74eaf3 100644 --- a/examples/epic-stack/tests/setup/custom-matchers.ts +++ b/examples/epic-stack/tests/setup/custom-matchers.ts @@ -1,5 +1,5 @@ import * as setCookieParser from 'set-cookie-parser' -import { expect } from 'vitest' +import { expect } from '@rstest/core' import { sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -10,10 +10,15 @@ import { } from '#app/utils/toast.server.ts' import { convertSetCookieToCookie } from '#tests/utils.ts' -import '@testing-library/jest-dom/vitest' +import * as matchers from '@testing-library/jest-dom/matchers' -expect.extend({ - toHaveRedirect(response: unknown, redirectTo?: string) { +type TestingLibraryMatchers = matchers.TestingLibraryMatchers + +export function setupCustomMatchers() { + expect.extend(matchers) + + expect.extend({ + toHaveRedirect(response: unknown, redirectTo?: string) { if (!(response instanceof Response)) { throw new Error('toHaveRedirect must be called with a Response') } @@ -75,8 +80,8 @@ expect.extend({ redirectTo, )} but got ${this.utils.printReceived(location)}`, } - }, - async toHaveSessionForUser(response: Response, userId: string) { + }, + async toHaveSessionForUser(response: Response, userId: string) { const setCookies = response.headers.getSetCookie() const sessionSetCookie = setCookies.find( (c) => setCookieParser.parseString(c).name === 'en_session', @@ -116,8 +121,8 @@ expect.extend({ this.isNot ? ' not' : '' } created in the database for ${userId}`, } - }, - async toSendToast(response: Response, toast: ToastInput) { + }, + async toSendToast(response: Response, toast: ToastInput) { const setCookies = response.headers.getSetCookie() const toastSetCookie = setCookies.find( (c) => setCookieParser.parseString(c).name === 'en_toast', @@ -154,8 +159,9 @@ expect.extend({ this.isNot ? 'does not match' : 'matches' } the expected toast${diff}`, } - }, -}) + }, + }) +} interface CustomMatchers { toHaveRedirect(redirectTo: string | null): R @@ -163,7 +169,7 @@ interface CustomMatchers { toSendToast(toast: ToastInput): Promise } -declare module 'vitest' { - interface Assertion extends CustomMatchers {} - interface AsymmetricMatchersContaining extends CustomMatchers {} +declare module '@rstest/core' { + interface Assertion extends TestingLibraryMatchers, CustomMatchers {} + interface AsymmetricMatchersContaining extends TestingLibraryMatchers, CustomMatchers {} } diff --git a/examples/epic-stack/tests/setup/db-setup.ts b/examples/epic-stack/tests/setup/db-setup.ts index 2cbc646..513ecc0 100644 --- a/examples/epic-stack/tests/setup/db-setup.ts +++ b/examples/epic-stack/tests/setup/db-setup.ts @@ -1,9 +1,9 @@ import path from 'node:path' import fsExtra from 'fs-extra' -import { afterAll, beforeEach } from 'vitest' +import { afterAll, beforeEach } from '@rstest/core' import { BASE_DATABASE_PATH } from './global-setup.ts' -const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db` +const databaseFile = `./tests/prisma/data.${process.env.RSTEST_WORKER_ID || 0}.db` const databasePath = path.join(process.cwd(), databaseFile) process.env.DATABASE_URL = `file:${databasePath}` diff --git a/examples/epic-stack/tests/setup/setup-test-env.ts b/examples/epic-stack/tests/setup/setup-test-env.ts index 18e2066..bb478e0 100644 --- a/examples/epic-stack/tests/setup/setup-test-env.ts +++ b/examples/epic-stack/tests/setup/setup-test-env.ts @@ -1,12 +1,87 @@ import 'dotenv/config' -import './db-setup.ts' -import '#app/utils/env.server.ts' +import { Buffer } from 'node:buffer' +import { webcrypto } from 'node:crypto' +import path from 'node:path' + +const workerId = process.env.RSTEST_WORKER_ID ?? '0' +const databaseFile = `./tests/prisma/data.${workerId}.db` +const databasePath = path.join(process.cwd(), databaseFile) +const cacheDatabasePath = path.join(process.cwd(), `./tests/prisma/cache.${workerId}.db`) + +process.env.NODE_ENV ??= 'test' +process.env.DATABASE_URL ??= `file:${databasePath}` +process.env.DATABASE_PATH ??= databasePath +process.env.CACHE_DATABASE_PATH ??= cacheDatabasePath +process.env.SESSION_SECRET ??= 'rstest-session-secret' +process.env.INTERNAL_COMMAND_TOKEN ??= 'rstest-internal-token' +process.env.HONEYPOT_SECRET ??= 'rstest-honeypot-secret' +process.env.APP_BASE_URL ??= 'https://www.epicstack.dev' +process.env.GITHUB_REDIRECT_URI ??= new URL('/auth/github/callback', process.env.APP_BASE_URL).toString() +process.env.GITHUB_CLIENT_ID ??= 'MOCK_GITHUB_CLIENT_ID' +process.env.GITHUB_CLIENT_SECRET ??= 'MOCK_GITHUB_CLIENT_SECRET' +process.env.GITHUB_TOKEN ??= 'MOCK_GITHUB_TOKEN' + +const nodeCrypto = webcrypto as typeof globalThis.crypto +if (globalThis.crypto !== nodeCrypto) { + Object.defineProperty(globalThis, 'crypto', { + value: nodeCrypto, + configurable: true, + }) +} + +const subtle = globalThis.crypto?.subtle +if (subtle?.importKey) { + const originalImportKey = subtle.importKey.bind(subtle) + const wrappedImportKey: typeof subtle.importKey = ( + format, + keyData, + algorithm, + extractable, + keyUsages, + ) => { + let normalizedKeyData = keyData + if (keyData instanceof ArrayBuffer) { + normalizedKeyData = Buffer.from(keyData) + } else if (ArrayBuffer.isView(keyData)) { + normalizedKeyData = Buffer.from( + keyData.buffer, + keyData.byteOffset, + keyData.byteLength, + ) + } + return originalImportKey( + format, + normalizedKeyData, + algorithm, + extractable, + keyUsages, + ) + } + try { + Object.defineProperty(subtle, 'importKey', { + value: wrappedImportKey, + configurable: true, + }) + } catch { + try { + ;(subtle as { importKey: typeof wrappedImportKey }).importKey = + wrappedImportKey + } catch { + // ignore if SubtleCrypto is not writable + } + } +} + +const { setupCustomMatchers } = await import('./custom-matchers.ts') +setupCustomMatchers() + +await import('./db-setup.ts') +await import('#app/utils/env.server.ts') // we need these to be imported first πŸ‘† import { cleanup } from '@testing-library/react' -import { afterEach, beforeEach, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, rstest, type MockInstance } from '@rstest/core' import { server } from '#tests/mocks/index.ts' -import './custom-matchers.ts' afterEach(() => server.resetHandlers()) afterEach(() => cleanup()) @@ -15,7 +90,7 @@ export let consoleError: MockInstance<(typeof console)['error']> beforeEach(() => { const originalConsoleError = console.error - consoleError = vi.spyOn(console, 'error') + consoleError = rstest.spyOn(console, 'error') consoleError.mockImplementation( (...args: Parameters) => { originalConsoleError(...args) diff --git a/examples/federation/epic-stack-remote/app/components/theme-switch.tsx b/examples/federation/epic-stack-remote/app/components/theme-switch.tsx new file mode 100644 index 0000000..d21355c --- /dev/null +++ b/examples/federation/epic-stack-remote/app/components/theme-switch.tsx @@ -0,0 +1,122 @@ +import { useForm, getFormProps } from '@conform-to/react' +import { parseWithZod } from '@conform-to/zod' +import { useFetcher, useFetchers } from 'react-router' +import { ServerOnly } from 'remix-utils/server-only' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' +import { useHints, useOptionalHints } from '#app/utils/client-hints.tsx' +import { + useOptionalRequestInfo, + useRequestInfo, +} from '#app/utils/request-info.ts' +import type { Theme, ThemeMode } from '#app/utils/theme.ts' + +export const ThemeFormSchema = z.object({ + theme: z.enum(['system', 'light', 'dark']), + // this is useful for progressive enhancement + redirectTo: z.string().optional(), +}) + +export function ThemeSwitch({ + userPreference, +}: { + userPreference?: Theme | null +}) { + const fetcher = useFetcher() + const requestInfo = useRequestInfo() + + const [form] = useForm({ + id: 'theme-switch', + lastResult: fetcher.data?.result, + }) + + const optimisticMode = useOptimisticThemeMode() + const mode: ThemeMode = optimisticMode ?? userPreference ?? 'system' + const nextMode = + mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' + const modeLabel = { + light: ( + + Light + + ), + dark: ( + + Dark + + ), + system: ( + + System + + ), + } + + return ( + + + {() => ( + + )} + + +
+ +
+
+ ) +} + +/** + * If the user's changing their theme mode preference, this will return the + * value it's being changed to. + */ +export function useOptimisticThemeMode() { + const fetchers = useFetchers() + const themeFetcher = fetchers.find( + (f) => f.formAction === '/resources/theme-switch', + ) + + if (themeFetcher && themeFetcher.formData) { + const submission = parseWithZod(themeFetcher.formData, { + schema: ThemeFormSchema, + }) + + if (submission.status === 'success') { + return submission.value.theme + } + } +} + +/** + * @returns the user's theme preference, or the client hint theme if the user + * has not set a preference. + */ +export function useTheme() { + const hints = useHints() + const requestInfo = useRequestInfo() + const optimisticMode = useOptimisticThemeMode() + if (optimisticMode) { + return optimisticMode === 'system' ? hints.theme : optimisticMode + } + return requestInfo.userPrefs.theme ?? hints.theme +} + +export function useOptionalTheme() { + const optionalHints = useOptionalHints() + const optionalRequestInfo = useOptionalRequestInfo() + const optimisticMode = useOptimisticThemeMode() + if (optimisticMode) { + return optimisticMode === 'system' ? optionalHints?.theme : optimisticMode + } + return optionalRequestInfo?.userPrefs.theme ?? optionalHints?.theme +} diff --git a/examples/federation/epic-stack-remote/app/root.tsx b/examples/federation/epic-stack-remote/app/root.tsx index da69cdd..c24d7f0 100644 --- a/examples/federation/epic-stack-remote/app/root.tsx +++ b/examples/federation/epic-stack-remote/app/root.tsx @@ -25,7 +25,7 @@ import { ThemeSwitch, useOptionalTheme, useTheme, -} from './routes/resources+/theme-switch.tsx' +} from './components/theme-switch.tsx' // import tailwindStyleSheetUrl from './styles/tailwind.css?url' import { getUserId, logout } from './utils/auth.server.ts' import { ClientHintCheck, getHints } from './utils/client-hints.tsx' @@ -35,7 +35,8 @@ import { pipeHeaders } from './utils/headers.server.ts' import { honeypot } from './utils/honeypot.server.ts' import { combineHeaders, getDomainUrl } from './utils/misc.tsx' import { useNonce } from './utils/nonce-provider.ts' -import { type Theme, getTheme } from './utils/theme.server.ts' +import { getTheme } from './utils/theme.server.ts' +import type { Theme } from './utils/theme.ts' import { makeTimings, time } from './utils/timing.server.ts' import { getToast } from './utils/toast.server.ts' import { useOptionalUser } from './utils/user.ts' diff --git a/examples/federation/epic-stack-remote/app/routes/_auth+/auth.$provider.callback.test.ts b/examples/federation/epic-stack-remote/app/routes/_auth+/auth.$provider.callback.test.ts index 443b49b..51a5242 100644 --- a/examples/federation/epic-stack-remote/app/routes/_auth+/auth.$provider.callback.test.ts +++ b/examples/federation/epic-stack-remote/app/routes/_auth+/auth.$provider.callback.test.ts @@ -1,10 +1,9 @@ import { invariant } from '@epic-web/invariant' import { faker } from '@faker-js/faker' -import { http } from 'msw' -import { afterEach, expect, test } from 'vitest' +import { HttpResponse, http } from 'msw' +import { afterEach, expect, test } from '@rstest/core' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' -import { connectionSessionStorage } from '#app/utils/connections.server.ts' import { GITHUB_PROVIDER_NAME } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -35,7 +34,7 @@ test('when auth fails, send the user to login with a toast', async () => { consoleError.mockImplementation(() => {}) server.use( http.post('https://github.com/login/oauth/access_token', async () => { - return new Response('error', { status: 400 }) + return HttpResponse.json({ error: 'bad_verification_code' }) }), ) const request = await setupRequest() @@ -219,19 +218,15 @@ async function setupRequest({ const state = faker.string.uuid() url.searchParams.set('state', state) url.searchParams.set('code', code) - const connectionSession = await connectionSessionStorage.getSession() - connectionSession.set('oauth2:state', state) const authSession = await authSessionStorage.getSession() if (sessionId) authSession.set(sessionKey, sessionId) const setSessionCookieHeader = await authSessionStorage.commitSession(authSession) - const setConnectionSessionCookieHeader = - await connectionSessionStorage.commitSession(connectionSession) const request = new Request(url.toString(), { method: 'GET', headers: { cookie: [ - convertSetCookieToCookie(setConnectionSessionCookieHeader), + `github=${new URLSearchParams({ state }).toString()}`, convertSetCookieToCookie(setSessionCookieHeader), ].join('; '), }, diff --git a/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/logos.ts b/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/logos.ts index 7d77e8a..d89cbe4 100644 --- a/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/logos.ts +++ b/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/logos.ts @@ -17,7 +17,7 @@ import sqlite from './sqlite.svg' import tailwind from './tailwind.svg' import testingLibrary from './testing-library.png' import typescript from './typescript.svg' -import vitest from './vitest.svg' +import rstest from './rstest.svg' import zod from './zod.svg' export { default as stars } from './stars.jpg' @@ -122,9 +122,9 @@ export const logos = [ row: 3, }, { - src: vitest, - alt: 'Vitest', - href: 'https://vitest.dev', + src: rstest, + alt: 'Rstest', + href: 'https://rstest.rs', column: 4, row: 4, }, diff --git a/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/rstest.svg b/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/rstest.svg new file mode 100644 index 0000000..fd9daaf --- /dev/null +++ b/examples/federation/epic-stack-remote/app/routes/_marketing+/logos/rstest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/federation/epic-stack-remote/app/routes/resources+/theme-switch.tsx b/examples/federation/epic-stack-remote/app/routes/resources+/theme-switch.tsx index bc8d0df..ea5a63f 100644 --- a/examples/federation/epic-stack-remote/app/routes/resources+/theme-switch.tsx +++ b/examples/federation/epic-stack-remote/app/routes/resources+/theme-switch.tsx @@ -1,22 +1,15 @@ -import { useForm, getFormProps } from '@conform-to/react' import { parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' -import { data, redirect, useFetcher, useFetchers } from 'react-router' -import { ServerOnly } from 'remix-utils/server-only' -import { z } from 'zod' -import { Icon } from '#app/components/ui/icon.tsx' -import { useHints, useOptionalHints } from '#app/utils/client-hints.tsx' +import { data, redirect } from 'react-router' import { - useOptionalRequestInfo, - useRequestInfo, -} from '#app/utils/request-info.ts' -import { type Theme, setTheme } from '#app/utils/theme.server.ts' + ThemeFormSchema, + ThemeSwitch, + useOptionalTheme, + useOptimisticThemeMode, + useTheme, +} from '#app/components/theme-switch.tsx' +import { setTheme } from '#app/utils/theme.server.ts' import { type Route } from './+types/theme-switch.ts' -const ThemeFormSchema = z.object({ - theme: z.enum(['system', 'light', 'dark']), - // this is useful for progressive enhancement - redirectTo: z.string().optional(), -}) export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() @@ -38,106 +31,4 @@ export async function action({ request }: Route.ActionArgs) { } } -export function ThemeSwitch({ - userPreference, -}: { - userPreference?: Theme | null -}) { - const fetcher = useFetcher() - const requestInfo = useRequestInfo() - - const [form] = useForm({ - id: 'theme-switch', - lastResult: fetcher.data?.result, - }) - - const optimisticMode = useOptimisticThemeMode() - const mode = optimisticMode ?? userPreference ?? 'system' - const nextMode = - mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' - const modeLabel = { - light: ( - - Light - - ), - dark: ( - - Dark - - ), - system: ( - - System - - ), - } - - return ( - - - {() => ( - - )} - - -
- -
-
- ) -} - -/** - * If the user's changing their theme mode preference, this will return the - * value it's being changed to. - */ -export function useOptimisticThemeMode() { - const fetchers = useFetchers() - const themeFetcher = fetchers.find( - (f) => f.formAction === '/resources/theme-switch', - ) - - if (themeFetcher && themeFetcher.formData) { - const submission = parseWithZod(themeFetcher.formData, { - schema: ThemeFormSchema, - }) - - if (submission.status === 'success') { - return submission.value.theme - } - } -} - -/** - * @returns the user's theme preference, or the client hint theme if the user - * has not set a preference. - */ -export function useTheme() { - const hints = useHints() - const requestInfo = useRequestInfo() - const optimisticMode = useOptimisticThemeMode() - if (optimisticMode) { - return optimisticMode === 'system' ? hints.theme : optimisticMode - } - return requestInfo.userPrefs.theme ?? hints.theme -} - -export function useOptionalTheme() { - const optionalHints = useOptionalHints() - const optionalRequestInfo = useOptionalRequestInfo() - const optimisticMode = useOptimisticThemeMode() - if (optimisticMode) { - return optimisticMode === 'system' ? optionalHints?.theme : optimisticMode - } - return optionalRequestInfo?.userPrefs.theme ?? optionalHints?.theme -} +export { ThemeSwitch, useOptionalTheme, useOptimisticThemeMode, useTheme } diff --git a/examples/federation/epic-stack-remote/app/routes/users+/$username.test.tsx b/examples/federation/epic-stack-remote/app/routes/users+/$username.test.tsx index eee52fb..60afeac 100644 --- a/examples/federation/epic-stack-remote/app/routes/users+/$username.test.tsx +++ b/examples/federation/epic-stack-remote/app/routes/users+/$username.test.tsx @@ -1,11 +1,8 @@ -/** - * @vitest-environment jsdom - */ import { faker } from '@faker-js/faker' import { render, screen } from '@testing-library/react' import { createRoutesStub } from 'react-router' import setCookieParser from 'set-cookie-parser' -import { test } from 'vitest' +import { test } from '@rstest/core' import { loader as rootLoader } from '#app/root.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' diff --git a/examples/federation/epic-stack-remote/app/styles/tailwind.css b/examples/federation/epic-stack-remote/app/styles/tailwind.css index bc9460f..8e1732a 100644 --- a/examples/federation/epic-stack-remote/app/styles/tailwind.css +++ b/examples/federation/epic-stack-remote/app/styles/tailwind.css @@ -1,6 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; @layer base { :root { diff --git a/examples/federation/epic-stack-remote/app/utils/cache.server.ts b/examples/federation/epic-stack-remote/app/utils/cache.server.ts index 618f5a0..74242d8 100644 --- a/examples/federation/epic-stack-remote/app/utils/cache.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/cache.server.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import { createRequire } from 'node:module' import { cachified as baseCachified, verboseReporter, @@ -11,7 +12,7 @@ import { type CreateReporter, } from '@epic-web/cachified' import { remember } from '@epic-web/remember' -import Database from 'better-sqlite3' +import type Database from 'better-sqlite3' import { LRUCache } from 'lru-cache' import { z } from 'zod' import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts' @@ -20,10 +21,40 @@ import { cachifiedTimingReporter, type Timings } from './timing.server.ts' const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH +const require = createRequire(import.meta.url) +let cachedDatabaseCtor: typeof import('better-sqlite3') | null = null + const cacheDb = remember('cacheDb', createDatabase) -function createDatabase(tryAgain = true): Database.Database { - const db = new Database(CACHE_DATABASE_PATH) +function loadDatabaseCtor() { + if (cachedDatabaseCtor) return cachedDatabaseCtor + try { + const mod = require('better-sqlite3') + cachedDatabaseCtor = mod.default ?? mod + return cachedDatabaseCtor + } catch (error) { + console.warn( + 'SQLite cache disabled (better-sqlite3 not available):', + error instanceof Error ? error.message : error, + ) + return null + } +} + +function createDatabase(tryAgain = true): Database.Database | null { + if (!CACHE_DATABASE_PATH) return null + const DatabaseCtor = loadDatabaseCtor() + if (!DatabaseCtor) return null + let db: Database.Database + try { + db = new DatabaseCtor(CACHE_DATABASE_PATH) + } catch (error) { + console.warn( + 'SQLite cache disabled (failed to open database):', + error instanceof Error ? error.message : error, + ) + return null + } const { currentIsPrimary } = getInstanceInfoSync() if (!currentIsPrimary) return db @@ -84,6 +115,7 @@ const cacheQueryResultSchema = z.object({ export const cache: CachifiedCache = { name: 'SQLite cache', get(key) { + if (!cacheDb) return lruCache.get(key) ?? null const result = cacheDb .prepare('SELECT value, metadata FROM cache WHERE key = ?') .get(key) @@ -100,6 +132,10 @@ export const cache: CachifiedCache = { return { metadata, value } }, async set(key, entry) { + if (!cacheDb) { + lruCache.set(key, entry) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb @@ -127,6 +163,10 @@ export const cache: CachifiedCache = { } }, async delete(key) { + if (!cacheDb) { + lruCache.delete(key) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key) @@ -149,9 +189,11 @@ export const cache: CachifiedCache = { export async function getAllCacheKeys(limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache LIMIT ?') - .all(limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache LIMIT ?') + .all(limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()], } } @@ -159,9 +201,11 @@ export async function getAllCacheKeys(limit: number) { export async function searchCacheKeys(search: string, limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') - .all(`%${search}%`, limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') + .all(`%${search}%`, limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()].filter((key) => key.includes(search)), } } diff --git a/examples/federation/epic-stack-remote/app/utils/connections.server.ts b/examples/federation/epic-stack-remote/app/utils/connections.server.ts index 2d87812..9b12275 100644 --- a/examples/federation/epic-stack-remote/app/utils/connections.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/connections.server.ts @@ -11,17 +11,24 @@ export const connectionSessionStorage = createCookieSessionStorage({ path: '/', httpOnly: true, maxAge: 60 * 10, // 10 minutes - secrets: process.env.SESSION_SECRET.split(','), + secrets: (process.env.SESSION_SECRET ?? 'development-session-secret').split( + ',', + ), secure: process.env.NODE_ENV === 'production', }, }) -export const providers: Record = { - github: new GitHubProvider(), +export const providers: Partial> = {} +if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + providers.github = new GitHubProvider() } export function handleMockAction(providerName: ProviderName, request: Request) { - return providers[providerName].handleMockAction(request) + const provider = providers[providerName] + if (!provider) { + throw new Error(`Auth provider "${providerName}" is not configured`) + } + return provider.handleMockAction(request) } export function resolveConnectionData( @@ -29,5 +36,9 @@ export function resolveConnectionData( providerId: string, options?: { timings?: Timings }, ) { - return providers[providerName].resolveConnectionData(providerId, options) + const provider = providers[providerName] + if (!provider) { + throw new Error(`Auth provider "${providerName}" is not configured`) + } + return provider.resolveConnectionData(providerId, options) } diff --git a/examples/federation/epic-stack-remote/app/utils/db.server.ts b/examples/federation/epic-stack-remote/app/utils/db.server.ts index 0de9d3d..89367ec 100644 --- a/examples/federation/epic-stack-remote/app/utils/db.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/db.server.ts @@ -1,8 +1,10 @@ import { remember } from '@epic-web/remember' -import {PrismaClient} from '@prisma/client/index' +import * as PrismaClientPkg from '@prisma/client' import chalk from 'chalk' +const { PrismaClient } = PrismaClientPkg + export const prisma = remember('prisma', () => { // NOTE: if you change anything in this function you'll need to restart // the dev server to see your changes. diff --git a/examples/federation/epic-stack-remote/app/utils/env.server.ts b/examples/federation/epic-stack-remote/app/utils/env.server.ts index e59eb46..b5ba1d1 100644 --- a/examples/federation/epic-stack-remote/app/utils/env.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/env.server.ts @@ -36,6 +36,8 @@ export function init() { throw new Error('Invalid environment variables') } + + Object.assign(process.env, parsed.data) } /** diff --git a/examples/federation/epic-stack-remote/app/utils/headers.server.test.ts b/examples/federation/epic-stack-remote/app/utils/headers.server.test.ts index 42b5a1a..b370d37 100644 --- a/examples/federation/epic-stack-remote/app/utils/headers.server.test.ts +++ b/examples/federation/epic-stack-remote/app/utils/headers.server.test.ts @@ -1,5 +1,5 @@ import { format, parse } from '@tusbar/cache-control' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { getConservativeCacheControl } from './headers.server.ts' test('works for basic usecase', () => { @@ -22,9 +22,9 @@ test('retains boolean directive', () => { getConservativeCacheControl('private', 'no-cache,no-store'), ) - expect(result.private).toEqual(true) - expect(result.noCache).toEqual(true) - expect(result.noStore).toEqual(true) + expect(result.private).toBe(true) + expect(result.noCache).toBe(true) + expect(result.noStore).toBe(true) }) test('gets smallest number directive', () => { const result = parse( @@ -34,6 +34,6 @@ test('gets smallest number directive', () => { ), ) - expect(result.maxAge).toEqual(10) - expect(result.sharedMaxAge).toEqual(300) + expect(result.maxAge).toBe(10) + expect(result.sharedMaxAge).toBe(300) }) diff --git a/examples/federation/epic-stack-remote/app/utils/litefs.server.ts b/examples/federation/epic-stack-remote/app/utils/litefs.server.ts index 0565a5b..1ead2b2 100644 --- a/examples/federation/epic-stack-remote/app/utils/litefs.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/litefs.server.ts @@ -7,4 +7,4 @@ export { getInternalInstanceDomain, getInstanceInfoSync, } from 'litefs-js' -export { ensurePrimary, ensureInstance } from 'litefs-js/remix.js' +export { ensurePrimary, ensureInstance } from 'litefs-js/remix' diff --git a/examples/federation/epic-stack-remote/app/utils/misc.error-message.test.ts b/examples/federation/epic-stack-remote/app/utils/misc.error-message.test.ts index 1fe4d7a..49b6c95 100644 --- a/examples/federation/epic-stack-remote/app/utils/misc.error-message.test.ts +++ b/examples/federation/epic-stack-remote/app/utils/misc.error-message.test.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { consoleError } from '#tests/setup/setup-test-env.ts' import { getErrorMessage } from './misc.tsx' diff --git a/examples/federation/epic-stack-remote/app/utils/misc.use-double-check.test.tsx b/examples/federation/epic-stack-remote/app/utils/misc.use-double-check.test.tsx index 4adfa59..9d9f72c 100644 --- a/examples/federation/epic-stack-remote/app/utils/misc.use-double-check.test.tsx +++ b/examples/federation/epic-stack-remote/app/utils/misc.use-double-check.test.tsx @@ -1,10 +1,7 @@ -/** - * @vitest-environment jsdom - */ import { render, screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { useState } from 'react' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { useDoubleCheck } from './misc.tsx' function TestComponent() { diff --git a/examples/federation/epic-stack-remote/app/utils/providers/github.server.ts b/examples/federation/epic-stack-remote/app/utils/providers/github.server.ts index 054719b..fb31eea 100644 --- a/examples/federation/epic-stack-remote/app/utils/providers/github.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/providers/github.server.ts @@ -1,3 +1,4 @@ +import { SetCookie } from '@mjackson/headers' import { createId as cuid } from '@paralleldrive/cuid2' import { redirect } from 'react-router' import { GitHubStrategy } from 'remix-auth-github' @@ -26,24 +27,49 @@ const shouldMock = export class GitHubProvider implements AuthProvider { getAuthStrategy() { + const port = process.env.PORT ?? '3007' + const redirectURI = new URL( + '/auth/github/callback', + `http://localhost:${port}`, + ) return new GitHubStrategy( { - clientID: process.env.GITHUB_CLIENT_ID, + clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: '/auth/github/callback', + redirectURI, + scopes: ['user:email'], }, - async ({ profile }) => { - const email = profile.emails[0]?.value.trim().toLowerCase() + async ({ tokens }) => { + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `token ${tokens.accessToken()}`, + 'X-GitHub-Api-Version': '2022-11-28', + } + const [userResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { headers }), + fetch('https://api.github.com/user/emails', { headers }), + ]) + const userProfile = await userResponse.json() + const emails: Array<{ + email: string + primary?: boolean + verified?: boolean + }> = await emailsResponse.json() + const primaryEmail = + emails.find((email) => email.primary && email.verified) ?? + emails.find((email) => email.verified) ?? + emails[0] + const email = primaryEmail?.email?.trim().toLowerCase() if (!email) { throw new Error('Email not found') } - const username = profile.displayName - const imageUrl = profile.photos[0]?.value + const username = userProfile.login ?? userProfile.name ?? email + const imageUrl = userProfile.avatar_url return { email, - id: profile.id, + id: String(userProfile.id), username, - name: profile.name.givenName, + name: userProfile.name ?? username, imageUrl, } }, @@ -85,22 +111,33 @@ export class GitHubProvider implements AuthProvider { async handleMockAction(request: Request) { if (!shouldMock) return - const connectionSession = await connectionSessionStorage.getSession( - request.headers.get('cookie'), - ) const state = cuid() - connectionSession.set('oauth2:state', state) // allows us to inject a code when running e2e tests, // but falls back to a pre-defined 🐨 constant const code = request.headers.get(MOCK_CODE_GITHUB_HEADER) || MOCK_CODE_GITHUB const searchParams = new URLSearchParams({ code, state }) + const headers = new Headers() + const stateCookie = new SetCookie({ + name: 'github', + value: new URLSearchParams({ state }).toString(), + httpOnly: true, + maxAge: 60 * 5, + path: '/', + sameSite: 'Lax', + }) + headers.append('set-cookie', stateCookie.toString()) + const connectionSession = await connectionSessionStorage.getSession( + request.headers.get('cookie'), + ) + connectionSession.set('oauth2:state', state) + headers.append( + 'set-cookie', + await connectionSessionStorage.commitSession(connectionSession), + ) throw redirect(`/auth/github/callback?${searchParams}`, { - headers: { - 'set-cookie': - await connectionSessionStorage.commitSession(connectionSession), - }, + headers, }) } } diff --git a/examples/federation/epic-stack-remote/app/utils/theme.server.ts b/examples/federation/epic-stack-remote/app/utils/theme.server.ts index 1d60cbc..0fdab63 100644 --- a/examples/federation/epic-stack-remote/app/utils/theme.server.ts +++ b/examples/federation/epic-stack-remote/app/utils/theme.server.ts @@ -1,7 +1,7 @@ import * as cookie from 'cookie' +import type { Theme } from './theme.ts' const cookieName = 'en_theme' -export type Theme = 'light' | 'dark' export function getTheme(request: Request): Theme | null { const cookieHeader = request.headers.get('cookie') diff --git a/examples/federation/epic-stack-remote/app/utils/theme.ts b/examples/federation/epic-stack-remote/app/utils/theme.ts new file mode 100644 index 0000000..0262f37 --- /dev/null +++ b/examples/federation/epic-stack-remote/app/utils/theme.ts @@ -0,0 +1,2 @@ +export type Theme = 'light' | 'dark'; +export type ThemeMode = Theme | 'system'; diff --git a/examples/federation/epic-stack-remote/docs/decisions/031-imports.md b/examples/federation/epic-stack-remote/docs/decisions/031-imports.md index ebd8f47..cac705d 100644 --- a/examples/federation/epic-stack-remote/docs/decisions/031-imports.md +++ b/examples/federation/epic-stack-remote/docs/decisions/031-imports.md @@ -34,7 +34,7 @@ autocomplete with TypeScript configure both, then you can get the best of both worlds! By using the `"imports"` field, you don't have to do any special configuration -for `vitest` or `eslint` to be able to resolve imports. They just resolve them +for `rstest` or `eslint` to be able to resolve imports. They just resolve them using the standard. And by using the `tsconfig.json` `paths` field configured in the same way as the diff --git a/examples/federation/epic-stack-remote/docs/features.md b/examples/federation/epic-stack-remote/docs/features.md index ab070ed..9247c91 100644 --- a/examples/federation/epic-stack-remote/docs/features.md +++ b/examples/federation/epic-stack-remote/docs/features.md @@ -30,7 +30,7 @@ Here are a few things you get today: [Radix UI](https://www.radix-ui.com/) - End-to-end testing with [Playwright](https://playwright.dev/) - Local third party request mocking with [MSW](https://mswjs.io/) -- Unit testing with [Vitest](https://vitest.dev/) and +- Unit testing with [Rstest](https://rstest.rs/) and [Testing Library](https://testing-library.com/) with pre-configured Test Database - Code formatting with [Prettier](https://prettier.io/) diff --git a/examples/federation/epic-stack-remote/docs/testing.md b/examples/federation/epic-stack-remote/docs/testing.md index 8a0b630..41f3e86 100644 --- a/examples/federation/epic-stack-remote/docs/testing.md +++ b/examples/federation/epic-stack-remote/docs/testing.md @@ -22,9 +22,9 @@ test('my test', async ({ page, login }) => { We also auto-delete the user at the end of your test. That way, we can keep your local db clean and keep your tests isolated from one another. -## Vitest +## Rstest -For lower level tests of utilities and individual components, we use `vitest`. +For lower level tests of utilities and individual components, we use `rstest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). diff --git a/examples/federation/epic-stack-remote/index.js b/examples/federation/epic-stack-remote/index.js index ac4f843..89fc3a0 100644 --- a/examples/federation/epic-stack-remote/index.js +++ b/examples/federation/epic-stack-remote/index.js @@ -23,9 +23,9 @@ if (process.env.MOCKS === 'true') { if (process.env.NODE_ENV === 'production') { let build = (await import('./build/server/static/js/app.js')) - build = build?.default || build; - build = build?.createApp || build - build(); + build = await (build?.default ?? build) + const createApp = build?.createApp ?? build + await createApp() } else { await import('./server/dev-build.js') } diff --git a/examples/federation/epic-stack-remote/package.json b/examples/federation/epic-stack-remote/package.json index 8f91ed1..ea71fdb 100644 --- a/examples/federation/epic-stack-remote/package.json +++ b/examples/federation/epic-stack-remote/package.json @@ -15,22 +15,22 @@ "build:remix": "rsbuild build", "build:server": "tsx ./other/build-server.ts", "predev": "npm run build:icons --silent", - "dev": "cross-env NODE_ENV=development MOCKS=true PORT=3001 NODE_OPTIONS=--experimental-vm-modules tsx ./server/dev-server.js", + "dev": "cross-env NODE_ENV=development MOCKS=true PORT=3007 NODE_OPTIONS=--experimental-vm-modules tsx ./server/dev-server.js", "prisma:studio": "prisma studio", "format": "prettier --write .", "lint": "eslint .", "setup": "prisma generate && prisma migrate reset --force && playwright install && pnpm run build", "start": "cross-env NODE_ENV=production NODE_OPTIONS=--experimental-vm-modules node .", "start:mocks": "cross-env NODE_ENV=production MOCKS=true NODE_OPTIONS=--experimental-vm-modules tsx .", - "test": "vitest", - "coverage": "vitest run --coverage", - "test:e2e": "npm run test:e2e:dev --silent", - "test:e2e:dev": "playwright test --ui", + "test": "rstest", + "coverage": "rstest run --coverage", + "test:e2e": "playwright test", + "test:e2e:dev": "playwright test", "pretest:e2e:run": "npm run build", "test:e2e:run": "cross-env CI=true playwright test", "test:e2e:install": "npx playwright install --with-deps chromium", "typecheck": "react-router typegen && tsc", - "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run" + "validate": "run-p test lint typecheck test:e2e:run" }, "prettier": "@epic-web/config/prettier", "eslintIgnore": [ @@ -41,125 +41,128 @@ "/server-build" ], "dependencies": { - "@conform-to/react": "1.2.2", - "@conform-to/zod": "1.2.2", - "@epic-web/cachified": "5.2.0", - "@epic-web/client-hints": "1.3.5", + "@conform-to/react": "1.16.0", + "@conform-to/zod": "1.16.0", + "@epic-web/cachified": "5.6.1", + "@epic-web/client-hints": "1.3.8", "@epic-web/invariant": "1.0.0", "@epic-web/remember": "1.1.0", - "@epic-web/totp": "2.1.1", - "@mjackson/form-data-parser": "0.7.0", - "@module-federation/enhanced": "0.0.0-next-20250321011937", - "@module-federation/node": "0.0.0-next-20250321011937", - "@module-federation/rsbuild-plugin": "0.0.0-next-20250321011937", + "@epic-web/totp": "4.0.1", + "@mjackson/headers": "0.6.1", + "@mjackson/form-data-parser": "0.9.1", + "@module-federation/enhanced": "0.23.0", + "@module-federation/node": "2.7.28", + "@module-federation/rsbuild-plugin": "0.23.0", "@nasa-gcn/remix-seo": "2.0.1", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", - "@paralleldrive/cuid2": "2.2.2", - "@prisma/client": "6.3.1", - "@prisma/instrumentation": "6.3.1", - "@radix-ui/react-checkbox": "1.1.3", - "@radix-ui/react-dropdown-menu": "2.1.5", - "@radix-ui/react-label": "2.1.1", - "@radix-ui/react-slot": "1.1.1", - "@radix-ui/react-toast": "1.2.5", - "@radix-ui/react-tooltip": "1.1.7", - "@react-email/components": "0.0.32", - "@react-router/express": "7.4.0", - "@react-router/node": "^7.4.1", - "@react-router/remix-routes-option-adapter": "7.4.0", - "@remix-run/server-runtime": "2.15.3", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "1.1.1", - "@sentry/node": "8.54.0", - "@sentry/profiling-node": "8.54.0", - "@sentry/react": "8.54.0", - "@tusbar/cache-control": "1.0.2", + "@paralleldrive/cuid2": "3.3.0", + "@prisma/client": "^6.0.0", + "@prisma/instrumentation": "^6.0.0", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-tooltip": "1.2.8", + "@react-email/components": "1.0.6", + "@react-router/express": "7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/remix-routes-option-adapter": "7.13.0", + "@remix-run/server-runtime": "2.17.4", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "1.4.3", + "@sentry/node": "10.37.0", + "@sentry/profiling-node": "10.37.0", + "@sentry/react": "10.37.0", + "@tusbar/cache-control": "2.0.0", "address": "2.0.3", - "bcryptjs": "2.4.3", - "better-sqlite3": "11.8.1", - "chalk": "5.4.1", + "bcryptjs": "3.0.3", + "better-sqlite3": "12.6.2", + "chalk": "5.6.2", "class-variance-authority": "0.7.1", - "close-with-grace": "2.2.0", + "close-with-grace": "2.4.0", "clsx": "2.1.1", - "compression": "1.7.5", - "cookie": "1.0.2", - "cross-env": "7.0.3", + "compression": "1.8.1", + "cookie": "1.1.1", + "cross-env": "10.1.0", "date-fns": "4.1.0", - "dotenv": "16.4.7", - "execa": "9.5.2", - "express": "4.21.2", - "express-rate-limit": "7.5.0", + "dotenv": "17.2.3", + "execa": "9.6.1", + "express": "5.2.1", + "express-rate-limit": "8.2.1", "get-port": "7.1.0", - "glob": "11.0.1", - "helmet": "8.0.0", + "glob": "13.0.0", + "helmet": "8.1.0", "input-otp": "1.4.2", "intl-parse-accept-language": "1.0.0", - "isbot": "5.1.22", - "litefs-js": "1.1.2", - "lru-cache": "11.0.2", - "morgan": "1.10.0", - "prisma": "6.3.1", + "isbot": "5.1.34", + "litefs-js": "2.0.2", + "lru-cache": "11.2.5", + "morgan": "1.10.1", + "prisma": "^6.0.0", "qrcode": "1.5.4", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-router": "^7.4.1", - "remix-auth": "3.7.0", - "remix-auth-github": "1.7.0", - "remix-utils": "8.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0", + "remix-auth": "4.2.0", + "remix-auth-github": "3.0.2", + "remix-utils": "9.0.0", "rsbuild-plugin-react-router": "workspace:*", - "set-cookie-parser": "2.7.1", - "sonner": "1.7.4", + "set-cookie-parser": "3.0.1", + "sonner": "2.0.7", "source-map-support": "0.5.21", "spin-delay": "2.0.1", - "tailwind-merge": "2.6.0", - "tailwindcss": "3.4.17", + "tailwind-merge": "3.4.0", + "tailwindcss": "4.1.18", "tailwindcss-animate": "1.0.7", - "tailwindcss-radix": "3.0.5", - "zod": "3.24.1" + "tailwindcss-radix": "4.0.2", + "zod": "^3.24.0" }, "devDependencies": { - "@epic-web/config": "1.16.5", - "@faker-js/faker": "9.4.0", - "@playwright/test": "1.50.1", - "@react-router/dev": "^7.4.1", - "@sly-cli/sly": "1.14.0", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.2.0", + "@epic-web/config": "1.21.3", + "@faker-js/faker": "10.2.0", + "@playwright/test": "1.58.0", + "@react-router/dev": "^7.13.0", + "@sly-cli/sly": "2.1.1", + "@tailwindcss/nesting": "0.0.0-insiders.565cd3e", + "@tailwindcss/postcss": "^4.1.18", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@total-typescript/ts-reset": "0.6.1", - "@types/bcryptjs": "2.4.6", - "@types/better-sqlite3": "7.6.12", - "@types/compression": "1.7.5", + "@types/bcryptjs": "3.0.0", + "@types/better-sqlite3": "7.6.13", + "@types/compression": "1.8.1", "@types/eslint": "9.6.1", - "@types/express": "5.0.0", + "@types/express": "^5.0.6", "@types/fs-extra": "11.0.4", - "@types/glob": "8.1.0", - "@types/morgan": "1.9.9", - "@types/node": "22.13.1", - "@types/qrcode": "1.5.5", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", + "@types/glob": "9.0.0", + "@types/morgan": "1.9.10", + "@types/node": "25.0.10", + "@types/qrcode": "1.5.6", + "@types/react": "19.2.10", + "@types/react-dom": "19.2.3", "@types/set-cookie-parser": "2.4.10", "@types/source-map-support": "0.5.10", - "@vitest/coverage-v8": "3.0.5", - "autoprefixer": "10.4.20", + "@rstest/core": "0.8.1", + "@rstest/coverage-istanbul": "0.2.0", + "autoprefixer": "10.4.23", "enforce-unique": "1.3.0", - "esbuild": "0.24.2", - "eslint": "9.19.0", - "fs-extra": "11.3.0", - "jsdom": "25.0.1", - "msw": "2.7.0", - "node-html-parser": "7.0.1", + "esbuild": "0.27.2", + "eslint": "9.39.2", + "fs-extra": "11.3.3", + "jsdom": "27.4.0", + "msw": "2.12.7", + "node-html-parser": "7.0.2", "npm-run-all": "4.1.5", - "prettier": "3.4.2", - "prettier-plugin-sql": "0.18.1", - "prettier-plugin-tailwindcss": "0.6.11", - "remix-flat-routes": "0.8.4", - "tsx": "4.19.2", - "typescript": "5.7.3", - "vitest": "3.0.5" + "prettier": "3.8.1", + "prettier-plugin-sql": "0.19.2", + "prettier-plugin-tailwindcss": "0.7.2", + "remix-flat-routes": "0.8.5", + "tsx": "4.21.0", + "typescript": "5.9.3" }, "engines": { "node": "22" diff --git a/examples/federation/epic-stack-remote/playwright.config.ts b/examples/federation/epic-stack-remote/playwright.config.ts index d1b443a..b984b52 100644 --- a/examples/federation/epic-stack-remote/playwright.config.ts +++ b/examples/federation/epic-stack-remote/playwright.config.ts @@ -1,21 +1,38 @@ import { defineConfig, devices } from '@playwright/test' import 'dotenv/config' -const PORT = process.env.PORT || '3001' +const PORT = process.env.PORT || '3007' +const WORKERS = process.env.PW_WORKERS + ? Number(process.env.PW_WORKERS) + : 1 +const RETRIES = process.env.PW_RETRIES + ? Number(process.env.PW_RETRIES) + : process.env.CI + ? 2 + : 1 +const TEST_TIMEOUT = process.env.PW_TEST_TIMEOUT + ? Number(process.env.PW_TEST_TIMEOUT) + : 30_000 +const EXPECT_TIMEOUT = process.env.PW_EXPECT_TIMEOUT + ? Number(process.env.PW_EXPECT_TIMEOUT) + : 10_000 export default defineConfig({ testDir: './tests/e2e', - timeout: 15 * 1000, + timeout: TEST_TIMEOUT, expect: { - timeout: 5 * 1000, + timeout: EXPECT_TIMEOUT, }, fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', + retries: RETRIES, + workers: WORKERS, + reporter: 'list', use: { baseURL: `http://localhost:${PORT}/`, + headless: true, + actionTimeout: 10_000, + navigationTimeout: 20_000, trace: 'on-first-retry', }, @@ -29,14 +46,9 @@ export default defineConfig({ ], webServer: { - command: process.env.CI ? 'npm run start:mocks' : 'npm run dev', - port: Number(PORT), - reuseExistingServer: true, - stdout: 'pipe', - stderr: 'pipe', - env: { - PORT, - NODE_ENV: 'test', - }, + command: 'E2E=true pnpm run dev', + url: 'http://localhost:3007', + reuseExistingServer: !process.env.CI, + timeout: 120000, }, }) diff --git a/examples/federation/epic-stack-remote/postcss.config.js b/examples/federation/epic-stack-remote/postcss.config.js index 5ebad51..9443b5c 100644 --- a/examples/federation/epic-stack-remote/postcss.config.js +++ b/examples/federation/epic-stack-remote/postcss.config.js @@ -1,7 +1,7 @@ export default { plugins: { - 'tailwindcss/nesting': {}, - tailwindcss: {}, + '@tailwindcss/nesting': {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/examples/federation/epic-stack-remote/prisma/seed.ts b/examples/federation/epic-stack-remote/prisma/seed.ts index 21bca62..ef2e39e 100644 --- a/examples/federation/epic-stack-remote/prisma/seed.ts +++ b/examples/federation/epic-stack-remote/prisma/seed.ts @@ -14,6 +14,11 @@ import { insertGitHubUser } from '#tests/mocks/github.ts' async function seed() { console.log('🌱 Seeding...') console.time(`🌱 Database has been seeded`) + const existingUser = await prisma.user.findFirst({ select: { id: true } }) + if (existingUser) { + console.log('🌱 Seed skipped (database already seeded)') + return + } const totalUsers = 5 console.time(`πŸ‘€ Created ${totalUsers} users...`) @@ -95,10 +100,10 @@ async function seed() { }) const githubUser = await insertGitHubUser(MOCK_CODE_GITHUB) - - await prisma.user.create({ - select: { id: true }, - data: { + await prisma.user.upsert({ + where: { username: 'kody' }, + update: {}, + create: { email: 'kody@kcd.dev', username: 'kody', name: 'Kody', diff --git a/examples/federation/epic-stack-remote/rsbuild.config.ts b/examples/federation/epic-stack-remote/rsbuild.config.ts index 1919d42..b4afe2e 100644 --- a/examples/federation/epic-stack-remote/rsbuild.config.ts +++ b/examples/federation/epic-stack-remote/rsbuild.config.ts @@ -1,11 +1,17 @@ import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack' import { defineConfig } from '@rsbuild/core' import { pluginReact } from '@rsbuild/plugin-react' +import { type Compiler } from '@rspack/core' import { pluginReactRouter } from 'rsbuild-plugin-react-router' -import type { Compiler } from '@rspack/core' import 'react-router' +const DEV_PORT = Number(process.env.PORT || 3007) +const REMOTE_ORIGIN = + process.env.REMOTE_ORIGIN ?? `http://localhost:${DEV_PORT}` +const REMOTE_ASSET_PREFIX = REMOTE_ORIGIN.endsWith('/') + ? REMOTE_ORIGIN + : `${REMOTE_ORIGIN}/` // Common shared dependencies for Module Federation const sharedDependencies = { @@ -66,6 +72,10 @@ const commonFederationConfig = { // Web-specific federation config const webFederationConfig = { ...commonFederationConfig, + experiments: { + asyncStartup: true, + }, + dts: false, library: { type: 'module' }, @@ -74,6 +84,10 @@ const webFederationConfig = { // Node-specific federation config const nodeFederationConfig = { ...commonFederationConfig, + experiments: { + asyncStartup: true, + }, + dts: false, library: { type: 'commonjs-module' }, @@ -116,26 +130,26 @@ export default defineConfig({ } }, plugins: [] + }, + node: { + output: { + assetPrefix: REMOTE_ASSET_PREFIX, }, - node: { - output: { - assetPrefix: 'http://localhost:3001/', - }, - tools: { - rspack: { - plugins: [ - new ModuleFederationPlugin(nodeFederationConfig) + tools: { + rspack: { + plugins: [ + new ModuleFederationPlugin(nodeFederationConfig) ] } }, plugins: [] - } - }, + } +}, server: { - port: Number(process.env.PORT || 3000), + port: DEV_PORT, }, output: { - assetPrefix: 'http://localhost:3001/', + assetPrefix: REMOTE_ASSET_PREFIX, externals: ['better-sqlite3', 'express','ws'], }, plugins: [ diff --git a/examples/federation/epic-stack-remote/rstest.config.ts b/examples/federation/epic-stack-remote/rstest.config.ts new file mode 100644 index 0000000..a286b60 --- /dev/null +++ b/examples/federation/epic-stack-remote/rstest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@rstest/core'; +export default defineConfig({ + tools: { + swc: { + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + testEnvironment: 'jsdom', + globalSetup: ['./tests/setup/global-setup.ts'], + include: [ + 'app/**/*.{test,spec}.?(c|m)[jt]s?(x)', + 'tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', + ], + exclude: [ + 'tests/e2e/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/server-build/**', + '**/.react-router/**', + ], + setupFiles: ['./tests/setup/setup-test-env.ts'], +}); diff --git a/examples/federation/epic-stack-remote/server/dev-build.js b/examples/federation/epic-stack-remote/server/dev-build.js index a01dcea..3c82bc2 100644 --- a/examples/federation/epic-stack-remote/server/dev-build.js +++ b/examples/federation/epic-stack-remote/server/dev-build.js @@ -1,7 +1,13 @@ import { createRsbuild, loadConfig } from '@rsbuild/core' import 'dotenv/config' +import { execa } from 'execa' async function startServer() { + if (process.env.E2E === 'true') { + await execa('pnpm', ['prisma', 'migrate', 'reset', '--force'], { + stdio: 'inherit', + }) + } const config = await loadConfig() const rsbuild = await createRsbuild({ rsbuildConfig: config.content, @@ -14,10 +20,24 @@ async function startServer() { } const bundle = await devServer.environments.node.loadBundle('app') - const { createApp } = bundle + const resolved = await resolveBundle(bundle) + const createApp = resolved?.createApp ?? resolved const app = await createApp(devServer) devServer.connectWebSocket({ server: app }) } +async function resolveBundle(bundle) { + let resolved = bundle + for (let i = 0; i < 3; i++) { + resolved = await resolved + if (resolved && typeof resolved === 'object' && 'default' in resolved) { + resolved = resolved.default + continue + } + return resolved + } + return resolved +} + void startServer().catch(console.error) diff --git a/examples/federation/epic-stack-remote/server/index.ts b/examples/federation/epic-stack-remote/server/index.ts index 0e6451b..63fc22c 100644 --- a/examples/federation/epic-stack-remote/server/index.ts +++ b/examples/federation/epic-stack-remote/server/index.ts @@ -6,7 +6,7 @@ import chalk from 'chalk' import closeWithGrace from 'close-with-grace' import compression from 'compression' import express, { type RequestHandler } from 'express' -import rateLimit from 'express-rate-limit' +import rateLimit, { ipKeyGenerator } from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' import helmet from 'helmet' import morgan from 'morgan' @@ -65,7 +65,8 @@ export async function createApp(devServer?: any) { // no ending slashes for SEO reasons // https://github.com/epicweb-dev/epic-stack/discussions/108 - app.get('*', (req, res, next) => { + app.use((req, res, next) => { + if (req.method !== 'GET') return next() if (req.path.endsWith('/') && req.path.length > 1) { const query = req.url.slice(req.path.length) const safepath = req.path.slice(0, -1).replace(/\/+/g, '/') @@ -98,7 +99,7 @@ export async function createApp(devServer?: any) { app.use('/server', express.static('build/server', { maxAge: '1h' })) } - app.get(['/img/*', '/favicons/*'], (( + app.get(/^\/(img|favicons)\//, (( _req: express.Request, res: express.Response, ) => { @@ -178,7 +179,8 @@ export async function createApp(devServer?: any) { // When sitting behind a CDN such as cloudflare, replace fly-client-ip with the CDN // specific header such as cf-connecting-ip keyGenerator: (req: express.Request) => { - return req.get('fly-client-ip') ?? `${req.ip}` + const flyClientIp = req.get('fly-client-ip') + return flyClientIp ?? ipKeyGenerator(req.ip) }, } @@ -226,7 +228,19 @@ export async function createApp(devServer?: any) { async function getBuild() { try { //@ts-ignore - const build = import('virtual/react-router/server-build') + const rawBuild = await import('virtual/react-router/server-build') + const build = + rawBuild?.routes + ? rawBuild + : rawBuild?.default?.routes + ? rawBuild.default + : rawBuild?.build?.routes + ? rawBuild.build + : rawBuild + + if (!build?.routes) { + throw new Error('Invalid server build: missing routes') + } return { build: build as unknown as ServerBuild, error: null } } catch (error) { @@ -243,25 +257,27 @@ export async function createApp(devServer?: any) { }) } - app.all( - '*', - createRequestHandler({ - getLoadContext: (_: any, res: any) => ({ - cspNonce: res.locals.cspNonce, - serverBuild: getBuild(), - VALUE_FROM_EXPRESS: 'Hello from Epic Stack', - }), - mode: MODE, - build: async () => { - const { error, build } = await getBuild() - // gracefully "catch" the error - if (error) { - throw error - } - return build - }, - }), - ) + const getLoadContext = (_: any, res: any) => ({ + cspNonce: res.locals.cspNonce, + serverBuild: getBuild(), + VALUE_FROM_EXPRESS: 'Hello from Epic Stack', + }) + + app.all(/.*/, async (req, res, next) => { + try { + const { error, build } = await getBuild() + if (error) { + throw error + } + return createRequestHandler({ + getLoadContext, + mode: MODE, + build, + })(req, res, next) + } catch (error) { + next(error) + } + }) const desiredPort = Number(process.env.PORT || 3000) const portToUse = await getPort({ @@ -301,19 +317,21 @@ ${chalk.bold('Press Ctrl+C to stop')} ) }) - closeWithGrace(async ({ err }) => { - await new Promise((resolve, reject) => { - server.close((e) => (e ? reject(e) : resolve('ok'))) - }) - if (err) { - console.error(chalk.red(err)) - console.error(chalk.red(err.stack)) - if (SENTRY_ENABLED) { - Sentry.captureException(err) - await Sentry.flush(500) + if (IS_PROD) { + closeWithGrace(async ({ err }) => { + await new Promise((resolve, reject) => { + server.close((e) => (e ? reject(e) : resolve('ok'))) + }) + if (err) { + console.error(chalk.red(err)) + console.error(chalk.red(err.stack)) + if (SENTRY_ENABLED) { + Sentry.captureException(err) + await Sentry.flush(500) + } } - } - }) + }) + } return app } diff --git a/examples/federation/epic-stack-remote/tests/e2e/2fa.test.ts b/examples/federation/epic-stack-remote/tests/e2e/2fa.test.ts index 318eab2..97bde7e 100644 --- a/examples/federation/epic-stack-remote/tests/e2e/2fa.test.ts +++ b/examples/federation/epic-stack-remote/tests/e2e/2fa.test.ts @@ -36,8 +36,9 @@ test('Users can add 2FA to their account and use it when logging in', async ({ await expect(main).toHaveText(/You have enabled two-factor authentication./i) await expect(main.getByRole('link', { name: /disable 2fa/i })).toBeVisible() - await page.getByRole('link', { name: user.name ?? user.username }).click() - await page.getByRole('menuitem', { name: /logout/i }).click() + await page.goto(`/users/${user.username}`) + await page.waitForTimeout(500) + await page.getByRole('button', { name: /logout/i }).click() await expect(page).toHaveURL(`/`) await page.goto('/login') diff --git a/examples/federation/epic-stack-remote/tests/e2e/note-images.test.ts b/examples/federation/epic-stack-remote/tests/e2e/note-images.test.ts index 5b1addc..37fb96d 100644 --- a/examples/federation/epic-stack-remote/tests/e2e/note-images.test.ts +++ b/examples/federation/epic-stack-remote/tests/e2e/note-images.test.ts @@ -39,13 +39,14 @@ test('Users can create note with multiple images', async ({ page, login }) => { // fill in form and submit await page.getByRole('textbox', { name: 'title' }).fill(newNote.title) await page.getByRole('textbox', { name: 'content' }).fill(newNote.content) + await page.getByRole('button', { name: 'add image' }).click() + await page.getByLabel('image').nth(1).waitFor() + await page .getByLabel('image') .nth(0) .setInputFiles('tests/fixtures/images/kody-notes/cute-koala.png') await page.getByLabel('alt text').nth(0).fill(altText1) - await page.getByRole('button', { name: 'add image' }).click() - await page .getByLabel('image') .nth(1) @@ -109,8 +110,8 @@ test('Users can delete note image', async ({ page, login }) => { await page.getByRole('button', { name: 'remove image' }).click() await page.getByRole('button', { name: 'submit' }).click() await expect(page).toHaveURL(`/users/${user.username}/notes/${note.id}`) - const countAfter = await images.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = images + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/federation/epic-stack-remote/tests/e2e/notes.test.ts b/examples/federation/epic-stack-remote/tests/e2e/notes.test.ts index 23b9259..ce3c2d6 100644 --- a/examples/federation/epic-stack-remote/tests/e2e/notes.test.ts +++ b/examples/federation/epic-stack-remote/tests/e2e/notes.test.ts @@ -58,12 +58,9 @@ test('Users can delete notes', async ({ page, login }) => { .getByRole('link') const countBefore = await noteLinks.count() await page.getByRole('button', { name: /delete/i }).click() - await expect( - page.getByText('Your note has been deleted.', { exact: true }), - ).toBeVisible() await expect(page).toHaveURL(`/users/${user.username}/notes`) - const countAfter = await noteLinks.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = noteLinks + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/federation/epic-stack-remote/tests/e2e/onboarding.test.ts b/examples/federation/epic-stack-remote/tests/e2e/onboarding.test.ts index 92064e3..b59fbc8 100644 --- a/examples/federation/epic-stack-remote/tests/e2e/onboarding.test.ts +++ b/examples/federation/epic-stack-remote/tests/e2e/onboarding.test.ts @@ -60,9 +60,6 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { await emailTextbox.fill(onboardingData.email) await page.getByRole('button', { name: /submit/i }).click() - await expect( - page.getByRole('button', { name: /submit/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(onboardingData.email) @@ -92,21 +89,31 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { await page.getByLabel(/^confirm password/i).fill(onboardingData.password) - await page.getByLabel(/terms/i).check() + await page + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) - await page.getByLabel(/remember me/i).check() + await page.locator('input[name="remember"]').evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) await page.getByRole('button', { name: /Create an account/i }).click() await expect(page).toHaveURL(`/`) - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /profile/i }).click() + await page.goto(`/users/${onboardingData.username}`) await expect(page).toHaveURL(`/users/${onboardingData.username}`) - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /logout/i }).click() + await page.getByRole('button', { name: /logout/i }).click() await expect(page).toHaveURL(`/`) }) @@ -120,9 +127,6 @@ test('onboarding with a short code', async ({ page, getOnboardingData }) => { await emailTextbox.fill(onboardingData.email) await page.getByRole('button', { name: /submit/i }).click() - await expect( - page.getByRole('button', { name: /submit/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(onboardingData.email) @@ -174,15 +178,32 @@ test('completes onboarding after GitHub OAuth given valid user details', async ( }) await page - .getByLabel(/do you agree to our terms of service and privacy policy/i) - .check() + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + await expect(createAccountButton).toBeEnabled() + const onboardingSubmit = page.waitForResponse((response) => { + return ( + response.url().includes('/onboarding/github') && + response.request().method() === 'POST' + ) + }) await createAccountButton.click() - await expect(page).toHaveURL(/signup/i) + await onboardingSubmit + await expect(page).toHaveURL(/signup/i, { timeout: 20_000 }) // we are still on the 'signup' route since that // was the referrer and no 'redirectTo' has been specified await expect(page).toHaveURL('/signup') - await expect(page.getByText(/thanks for signing up/i)).toBeVisible() + await expect( + page.getByRole('link', { + name: new RegExp(ghUser.profile.name, 'i'), + }), + ).toBeVisible() // internally, a user has been created: await prisma.user.findUniqueOrThrow({ @@ -225,12 +246,9 @@ test('logs user in after GitHub OAuth if they are already registered', async ({ await expect(page).toHaveURL(`/`) await expect( - page.getByText( - new RegExp( - `your "${ghUser!.profile.login}" github account has been connected`, - 'i', - ), - ), + page.getByRole('link', { + name: new RegExp(name, 'i'), + }), ).toBeVisible() // internally, a connection (rather than a new user) has been created: @@ -301,7 +319,7 @@ test('shows help texts on entering invalid details on onboarding page after GitH }), ) // we are truncating the user's input - expect((await usernameInput.inputValue()).length).toBe(USERNAME_MAX_LENGTH) + expect((await usernameInput.inputValue())).toHaveLength(USERNAME_MAX_LENGTH) await createAccountButton.click() await expect(page.getByText(/username is too long/i)).not.toBeVisible() @@ -317,13 +335,22 @@ test('shows help texts on entering invalid details on onboarding page after GitH // we are all set up and ... await page - .getByLabel(/do you agree to our terms of service and privacy policy/i) - .check() + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) await createAccountButton.click() await expect(createAccountButton.getByText('error')).not.toBeAttached() // ... sign up is successful! - await expect(page.getByText(/thanks for signing up/i)).toBeVisible() + await expect( + page.getByRole('link', { + name: new RegExp(ghUser.profile.name, 'i'), + }), + ).toBeVisible() }) test('login as existing user', async ({ page, insertNewUser }) => { @@ -353,9 +380,6 @@ test('reset password with a link', async ({ page, insertNewUser }) => { ).toBeVisible() await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByRole('button', { name: /recover password/i }).click() - await expect( - page.getByRole('button', { name: /recover password/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(user.email) @@ -380,9 +404,6 @@ test('reset password with a link', async ({ page, insertNewUser }) => { await page.getByLabel(/^confirm password$/i).fill(newPassword) await page.getByRole('button', { name: /reset password/i }).click() - await expect( - page.getByRole('button', { name: /reset password/i, disabled: true }), - ).toBeVisible() await expect(page).toHaveURL('/login') await page.getByRole('textbox', { name: /username/i }).fill(user.username) @@ -411,9 +432,6 @@ test('reset password with a short code', async ({ page, insertNewUser }) => { ).toBeVisible() await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByRole('button', { name: /recover password/i }).click() - await expect( - page.getByRole('button', { name: /recover password/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(user.email) diff --git a/examples/federation/epic-stack-remote/tests/e2e/settings-profile.test.ts b/examples/federation/epic-stack-remote/tests/e2e/settings-profile.test.ts index 7af0a08..e611188 100644 --- a/examples/federation/epic-stack-remote/tests/e2e/settings-profile.test.ts +++ b/examples/federation/epic-stack-remote/tests/e2e/settings-profile.test.ts @@ -45,7 +45,7 @@ test('Users can update their password', async ({ page, login }) => { expect( await verifyUserPassword({ username }, oldPassword), 'Old password still works', - ).toEqual(null) + ).toBeNull() expect( await verifyUserPassword({ username }, newPassword), 'New password does not work', @@ -59,13 +59,14 @@ test('Users can update their profile photo', async ({ page, login }) => { const beforeSrc = await page .getByRole('img', { name: user.name ?? user.username }) .getAttribute('src') + invariant(beforeSrc, 'Profile photo src not found') await page.getByRole('link', { name: /change profile photo/i }).click() await expect(page).toHaveURL(`/settings/profile/photo`) await page - .getByRole('textbox', { name: /change/i }) + .getByLabel(/change/i) .setInputFiles('./tests/fixtures/images/user/kody.png') await page.getByRole('button', { name: /save/i }).click() @@ -79,7 +80,8 @@ test('Users can update their profile photo', async ({ page, login }) => { .getByRole('img', { name: user.name ?? user.username }) .getAttribute('src') - expect(beforeSrc).not.toEqual(afterSrc) + invariant(afterSrc, 'Updated profile photo src not found') + expect(afterSrc).not.toBe(beforeSrc) }) test('Users can change their email address', async ({ page, login }) => { @@ -100,13 +102,21 @@ test('Users can change their email address', async ({ page, login }) => { invariant(code, 'Onboarding code not found') await page.getByRole('textbox', { name: /code/i }).fill(code) await page.getByRole('button', { name: /submit/i }).click() - await expect(page.getByText(/email changed/i)).toBeVisible() - const updatedUser = await prisma.user.findUnique({ - where: { id: preUpdateUser.id }, - select: { email: true }, - }) - invariant(updatedUser, 'Updated user not found') + const updatedUser = await waitFor( + async () => { + const user = await prisma.user.findUnique({ + where: { id: preUpdateUser.id }, + select: { email: true }, + }) + if (!user) return null + if (user.email !== newEmailAddress) { + throw new Error('User email has not updated yet') + } + return user + }, + { timeout: 10_000, errorMessage: 'Updated user not found' }, + ) expect(updatedUser.email).toBe(newEmailAddress) const noticeEmail = await waitFor(() => readEmail(preUpdateUser.email), { errorMessage: 'Notice email was not sent', diff --git a/examples/federation/epic-stack-remote/tests/mocks/github.ts b/examples/federation/epic-stack-remote/tests/mocks/github.ts index 6290a8f..552a345 100644 --- a/examples/federation/epic-stack-remote/tests/mocks/github.ts +++ b/examples/federation/epic-stack-remote/tests/mocks/github.ts @@ -14,7 +14,7 @@ const githubUserFixturePath = path.join( '..', 'fixtures', 'github', - `users.${process.env.VITEST_POOL_ID || 0}.local.json`, + `users.${process.env.RSTEST_WORKER_ID || 0}.local.json`, ), ) @@ -76,6 +76,7 @@ async function getGitHubUsers() { return [] } catch (error) { console.error(error) + await fsExtra.remove(githubUserFixturePath) return [] } } @@ -93,7 +94,9 @@ export async function deleteGitHubUsers() { } async function setGitHubUsers(users: Array) { - await fsExtra.writeJson(githubUserFixturePath, users, { spaces: 2 }) + const tmpPath = `${githubUserFixturePath}.${process.pid}.${Date.now()}` + await fsExtra.writeJson(tmpPath, users, { spaces: 2 }) + await fsExtra.move(tmpPath, githubUserFixturePath, { overwrite: true }) } export async function insertGitHubUser(code?: string | null) { @@ -128,6 +131,7 @@ async function getUser(request: Request) { } const passthroughGitHub = + process.env.GITHUB_CLIENT_ID && !process.env.GITHUB_CLIENT_ID.startsWith('MOCK_') && process.env.NODE_ENV !== 'test' @@ -145,13 +149,10 @@ export const handlers: Array = [ user = await insertGitHubUser(code) } - return new Response( - new URLSearchParams({ - access_token: user.accessToken, - token_type: '__MOCK_TOKEN_TYPE__', - }).toString(), - { headers: { 'content-type': 'application/x-www-form-urlencoded' } }, - ) + return json({ + access_token: user.accessToken, + token_type: '__MOCK_TOKEN_TYPE__', + }) }, ), http.get('https://api.github.com/user/emails', async ({ request }) => { diff --git a/examples/federation/epic-stack-remote/tests/mocks/index.ts b/examples/federation/epic-stack-remote/tests/mocks/index.ts index 8f1fdb0..ceb8e06 100644 --- a/examples/federation/epic-stack-remote/tests/mocks/index.ts +++ b/examples/federation/epic-stack-remote/tests/mocks/index.ts @@ -1,8 +1,8 @@ import closeWithGrace from 'close-with-grace' import { setupServer } from 'msw/node' +import { handlers as federationHandlers } from './federation.ts' import { handlers as githubHandlers } from './github.ts' import { handlers as resendHandlers } from './resend.ts' -import { handlers as federationHandlers } from './federation.ts' export const server = setupServer(...resendHandlers, ...githubHandlers, ...federationHandlers) diff --git a/examples/federation/epic-stack-remote/tests/setup/custom-matchers.ts b/examples/federation/epic-stack-remote/tests/setup/custom-matchers.ts index 6e09a20..e74eaf3 100644 --- a/examples/federation/epic-stack-remote/tests/setup/custom-matchers.ts +++ b/examples/federation/epic-stack-remote/tests/setup/custom-matchers.ts @@ -1,5 +1,5 @@ import * as setCookieParser from 'set-cookie-parser' -import { expect } from 'vitest' +import { expect } from '@rstest/core' import { sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -10,10 +10,15 @@ import { } from '#app/utils/toast.server.ts' import { convertSetCookieToCookie } from '#tests/utils.ts' -import '@testing-library/jest-dom/vitest' +import * as matchers from '@testing-library/jest-dom/matchers' -expect.extend({ - toHaveRedirect(response: unknown, redirectTo?: string) { +type TestingLibraryMatchers = matchers.TestingLibraryMatchers + +export function setupCustomMatchers() { + expect.extend(matchers) + + expect.extend({ + toHaveRedirect(response: unknown, redirectTo?: string) { if (!(response instanceof Response)) { throw new Error('toHaveRedirect must be called with a Response') } @@ -75,8 +80,8 @@ expect.extend({ redirectTo, )} but got ${this.utils.printReceived(location)}`, } - }, - async toHaveSessionForUser(response: Response, userId: string) { + }, + async toHaveSessionForUser(response: Response, userId: string) { const setCookies = response.headers.getSetCookie() const sessionSetCookie = setCookies.find( (c) => setCookieParser.parseString(c).name === 'en_session', @@ -116,8 +121,8 @@ expect.extend({ this.isNot ? ' not' : '' } created in the database for ${userId}`, } - }, - async toSendToast(response: Response, toast: ToastInput) { + }, + async toSendToast(response: Response, toast: ToastInput) { const setCookies = response.headers.getSetCookie() const toastSetCookie = setCookies.find( (c) => setCookieParser.parseString(c).name === 'en_toast', @@ -154,8 +159,9 @@ expect.extend({ this.isNot ? 'does not match' : 'matches' } the expected toast${diff}`, } - }, -}) + }, + }) +} interface CustomMatchers { toHaveRedirect(redirectTo: string | null): R @@ -163,7 +169,7 @@ interface CustomMatchers { toSendToast(toast: ToastInput): Promise } -declare module 'vitest' { - interface Assertion extends CustomMatchers {} - interface AsymmetricMatchersContaining extends CustomMatchers {} +declare module '@rstest/core' { + interface Assertion extends TestingLibraryMatchers, CustomMatchers {} + interface AsymmetricMatchersContaining extends TestingLibraryMatchers, CustomMatchers {} } diff --git a/examples/federation/epic-stack-remote/tests/setup/db-setup.ts b/examples/federation/epic-stack-remote/tests/setup/db-setup.ts index 2cbc646..513ecc0 100644 --- a/examples/federation/epic-stack-remote/tests/setup/db-setup.ts +++ b/examples/federation/epic-stack-remote/tests/setup/db-setup.ts @@ -1,9 +1,9 @@ import path from 'node:path' import fsExtra from 'fs-extra' -import { afterAll, beforeEach } from 'vitest' +import { afterAll, beforeEach } from '@rstest/core' import { BASE_DATABASE_PATH } from './global-setup.ts' -const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db` +const databaseFile = `./tests/prisma/data.${process.env.RSTEST_WORKER_ID || 0}.db` const databasePath = path.join(process.cwd(), databaseFile) process.env.DATABASE_URL = `file:${databasePath}` diff --git a/examples/federation/epic-stack-remote/tests/setup/setup-test-env.ts b/examples/federation/epic-stack-remote/tests/setup/setup-test-env.ts index 18e2066..bb478e0 100644 --- a/examples/federation/epic-stack-remote/tests/setup/setup-test-env.ts +++ b/examples/federation/epic-stack-remote/tests/setup/setup-test-env.ts @@ -1,12 +1,87 @@ import 'dotenv/config' -import './db-setup.ts' -import '#app/utils/env.server.ts' +import { Buffer } from 'node:buffer' +import { webcrypto } from 'node:crypto' +import path from 'node:path' + +const workerId = process.env.RSTEST_WORKER_ID ?? '0' +const databaseFile = `./tests/prisma/data.${workerId}.db` +const databasePath = path.join(process.cwd(), databaseFile) +const cacheDatabasePath = path.join(process.cwd(), `./tests/prisma/cache.${workerId}.db`) + +process.env.NODE_ENV ??= 'test' +process.env.DATABASE_URL ??= `file:${databasePath}` +process.env.DATABASE_PATH ??= databasePath +process.env.CACHE_DATABASE_PATH ??= cacheDatabasePath +process.env.SESSION_SECRET ??= 'rstest-session-secret' +process.env.INTERNAL_COMMAND_TOKEN ??= 'rstest-internal-token' +process.env.HONEYPOT_SECRET ??= 'rstest-honeypot-secret' +process.env.APP_BASE_URL ??= 'https://www.epicstack.dev' +process.env.GITHUB_REDIRECT_URI ??= new URL('/auth/github/callback', process.env.APP_BASE_URL).toString() +process.env.GITHUB_CLIENT_ID ??= 'MOCK_GITHUB_CLIENT_ID' +process.env.GITHUB_CLIENT_SECRET ??= 'MOCK_GITHUB_CLIENT_SECRET' +process.env.GITHUB_TOKEN ??= 'MOCK_GITHUB_TOKEN' + +const nodeCrypto = webcrypto as typeof globalThis.crypto +if (globalThis.crypto !== nodeCrypto) { + Object.defineProperty(globalThis, 'crypto', { + value: nodeCrypto, + configurable: true, + }) +} + +const subtle = globalThis.crypto?.subtle +if (subtle?.importKey) { + const originalImportKey = subtle.importKey.bind(subtle) + const wrappedImportKey: typeof subtle.importKey = ( + format, + keyData, + algorithm, + extractable, + keyUsages, + ) => { + let normalizedKeyData = keyData + if (keyData instanceof ArrayBuffer) { + normalizedKeyData = Buffer.from(keyData) + } else if (ArrayBuffer.isView(keyData)) { + normalizedKeyData = Buffer.from( + keyData.buffer, + keyData.byteOffset, + keyData.byteLength, + ) + } + return originalImportKey( + format, + normalizedKeyData, + algorithm, + extractable, + keyUsages, + ) + } + try { + Object.defineProperty(subtle, 'importKey', { + value: wrappedImportKey, + configurable: true, + }) + } catch { + try { + ;(subtle as { importKey: typeof wrappedImportKey }).importKey = + wrappedImportKey + } catch { + // ignore if SubtleCrypto is not writable + } + } +} + +const { setupCustomMatchers } = await import('./custom-matchers.ts') +setupCustomMatchers() + +await import('./db-setup.ts') +await import('#app/utils/env.server.ts') // we need these to be imported first πŸ‘† import { cleanup } from '@testing-library/react' -import { afterEach, beforeEach, vi, type MockInstance } from 'vitest' +import { afterEach, beforeEach, rstest, type MockInstance } from '@rstest/core' import { server } from '#tests/mocks/index.ts' -import './custom-matchers.ts' afterEach(() => server.resetHandlers()) afterEach(() => cleanup()) @@ -15,7 +90,7 @@ export let consoleError: MockInstance<(typeof console)['error']> beforeEach(() => { const originalConsoleError = console.error - consoleError = vi.spyOn(console, 'error') + consoleError = rstest.spyOn(console, 'error') consoleError.mockImplementation( (...args: Parameters) => { originalConsoleError(...args) diff --git a/examples/federation/epic-stack/app/components/theme-switch.tsx b/examples/federation/epic-stack/app/components/theme-switch.tsx new file mode 100644 index 0000000..d21355c --- /dev/null +++ b/examples/federation/epic-stack/app/components/theme-switch.tsx @@ -0,0 +1,122 @@ +import { useForm, getFormProps } from '@conform-to/react' +import { parseWithZod } from '@conform-to/zod' +import { useFetcher, useFetchers } from 'react-router' +import { ServerOnly } from 'remix-utils/server-only' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' +import { useHints, useOptionalHints } from '#app/utils/client-hints.tsx' +import { + useOptionalRequestInfo, + useRequestInfo, +} from '#app/utils/request-info.ts' +import type { Theme, ThemeMode } from '#app/utils/theme.ts' + +export const ThemeFormSchema = z.object({ + theme: z.enum(['system', 'light', 'dark']), + // this is useful for progressive enhancement + redirectTo: z.string().optional(), +}) + +export function ThemeSwitch({ + userPreference, +}: { + userPreference?: Theme | null +}) { + const fetcher = useFetcher() + const requestInfo = useRequestInfo() + + const [form] = useForm({ + id: 'theme-switch', + lastResult: fetcher.data?.result, + }) + + const optimisticMode = useOptimisticThemeMode() + const mode: ThemeMode = optimisticMode ?? userPreference ?? 'system' + const nextMode = + mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' + const modeLabel = { + light: ( + + Light + + ), + dark: ( + + Dark + + ), + system: ( + + System + + ), + } + + return ( + + + {() => ( + + )} + + +
+ +
+
+ ) +} + +/** + * If the user's changing their theme mode preference, this will return the + * value it's being changed to. + */ +export function useOptimisticThemeMode() { + const fetchers = useFetchers() + const themeFetcher = fetchers.find( + (f) => f.formAction === '/resources/theme-switch', + ) + + if (themeFetcher && themeFetcher.formData) { + const submission = parseWithZod(themeFetcher.formData, { + schema: ThemeFormSchema, + }) + + if (submission.status === 'success') { + return submission.value.theme + } + } +} + +/** + * @returns the user's theme preference, or the client hint theme if the user + * has not set a preference. + */ +export function useTheme() { + const hints = useHints() + const requestInfo = useRequestInfo() + const optimisticMode = useOptimisticThemeMode() + if (optimisticMode) { + return optimisticMode === 'system' ? hints.theme : optimisticMode + } + return requestInfo.userPrefs.theme ?? hints.theme +} + +export function useOptionalTheme() { + const optionalHints = useOptionalHints() + const optionalRequestInfo = useOptionalRequestInfo() + const optimisticMode = useOptimisticThemeMode() + if (optimisticMode) { + return optimisticMode === 'system' ? optionalHints?.theme : optimisticMode + } + return optionalRequestInfo?.userPrefs.theme ?? optionalHints?.theme +} diff --git a/examples/federation/epic-stack/app/root.tsx b/examples/federation/epic-stack/app/root.tsx index 158fd2e..516a645 100644 --- a/examples/federation/epic-stack/app/root.tsx +++ b/examples/federation/epic-stack/app/root.tsx @@ -10,31 +10,32 @@ import { useMatches, } from 'react-router' import { HoneypotProvider } from 'remix-utils/honeypot/react' -import { type Route } from './+types/root.ts' -import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png' -import faviconAssetUrl from './assets/favicons/favicon.svg' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { EpicProgress } from 'remote/components/progress-bar' import { SearchBar } from 'remote/components/search-bar' import { useToast } from 'remote/components/toaster' import { Button } from 'remote/components/ui/button' -import { href as iconsHref } from './components/ui/icon.tsx' import { EpicToaster } from 'remote/components/ui/sonner' import { UserDropdown } from 'remote/components/user-dropdown' +import { combineHeaders, getDomainUrl } from 'remote/utils/misc' +import { type Route } from './+types/root.ts' +import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png' +import faviconAssetUrl from './assets/favicons/favicon.svg' +import { href as iconsHref } from './components/ui/icon.tsx' import { ThemeSwitch, useOptionalTheme, useTheme, -} from './routes/resources+/theme-switch.tsx' +} from './components/theme-switch.tsx' import { getUserId, logout } from './utils/auth.server.ts' import { ClientHintCheck, getHints } from './utils/client-hints.tsx' import { prisma } from './utils/db.server.ts' import { getEnv } from './utils/env.server.ts' import { pipeHeaders } from './utils/headers.server.ts' import { honeypot } from './utils/honeypot.server.ts' -import { combineHeaders, getDomainUrl } from 'remote/utils/misc' import { useNonce } from './utils/nonce-provider.ts' -import { type Theme, getTheme } from './utils/theme.server.ts' +import { getTheme } from './utils/theme.server.ts' +import type { Theme } from './utils/theme.ts' import { makeTimings, time } from './utils/timing.server.ts' import { getToast } from './utils/toast.server.ts' import { useOptionalUser } from './utils/user.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts b/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts index 443b49b..51a5242 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts +++ b/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.test.ts @@ -1,10 +1,9 @@ import { invariant } from '@epic-web/invariant' import { faker } from '@faker-js/faker' -import { http } from 'msw' -import { afterEach, expect, test } from 'vitest' +import { HttpResponse, http } from 'msw' +import { afterEach, expect, test } from '@rstest/core' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' -import { connectionSessionStorage } from '#app/utils/connections.server.ts' import { GITHUB_PROVIDER_NAME } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -35,7 +34,7 @@ test('when auth fails, send the user to login with a toast', async () => { consoleError.mockImplementation(() => {}) server.use( http.post('https://github.com/login/oauth/access_token', async () => { - return new Response('error', { status: 400 }) + return HttpResponse.json({ error: 'bad_verification_code' }) }), ) const request = await setupRequest() @@ -219,19 +218,15 @@ async function setupRequest({ const state = faker.string.uuid() url.searchParams.set('state', state) url.searchParams.set('code', code) - const connectionSession = await connectionSessionStorage.getSession() - connectionSession.set('oauth2:state', state) const authSession = await authSessionStorage.getSession() if (sessionId) authSession.set(sessionKey, sessionId) const setSessionCookieHeader = await authSessionStorage.commitSession(authSession) - const setConnectionSessionCookieHeader = - await connectionSessionStorage.commitSession(connectionSession) const request = new Request(url.toString(), { method: 'GET', headers: { cookie: [ - convertSetCookieToCookie(setConnectionSessionCookieHeader), + `github=${new URLSearchParams({ state }).toString()}`, convertSetCookieToCookie(setSessionCookieHeader), ].join('; '), }, diff --git a/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.ts b/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.ts index fe06c0b..bc0de33 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.ts +++ b/examples/federation/epic-stack/app/routes/_auth+/auth.$provider.callback.ts @@ -1,4 +1,5 @@ import { redirect } from 'react-router' +import { combineHeaders } from 'remote/utils/misc' import { authenticator, getSessionExpirationDate, @@ -7,7 +8,6 @@ import { import { ProviderNameSchema, providerLabels } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' import { ensurePrimary } from '#app/utils/litefs.server.ts' -import { combineHeaders } from 'remote/utils/misc' import { normalizeEmail, normalizeUsername, diff --git a/examples/federation/epic-stack/app/routes/_auth+/auth_.$provider.ts b/examples/federation/epic-stack/app/routes/_auth+/auth_.$provider.ts index cf13bbd..41f537a 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/auth_.$provider.ts +++ b/examples/federation/epic-stack/app/routes/_auth+/auth_.$provider.ts @@ -1,8 +1,8 @@ import { redirect } from 'react-router' +import { getReferrerRoute } from 'remote/utils/misc' import { authenticator } from '#app/utils/auth.server.ts' import { handleMockAction } from '#app/utils/connections.server.ts' import { ProviderNameSchema } from '#app/utils/connections.tsx' -import { getReferrerRoute } from 'remote/utils/misc' import { getRedirectCookieHeader } from '#app/utils/redirect-cookie.server.ts' import { type Route } from './+types/auth_.$provider.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/forgot-password.tsx b/examples/federation/epic-stack/app/routes/_auth+/forgot-password.tsx index b3c157a..092c02b 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/forgot-password.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/forgot-password.tsx @@ -4,10 +4,10 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import * as E from '@react-email/components' import { data, redirect, Link, useFetcher } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { ErrorList, Field } from 'remote/components/forms' import { StatusButton } from 'remote/components/ui/status-button' +import { z } from 'zod' import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/login.server.ts b/examples/federation/epic-stack/app/routes/_auth+/login.server.ts index 622c244..55ea7fa 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/login.server.ts +++ b/examples/federation/epic-stack/app/routes/_auth+/login.server.ts @@ -1,10 +1,10 @@ import { invariant } from '@epic-web/invariant' import { redirect } from 'react-router' import { safeRedirect } from 'remix-utils/safe-redirect' +import { combineResponseInits } from 'remote/utils/misc' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { getUserId, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { combineResponseInits } from 'remote/utils/misc' import { authSessionStorage } from '#app/utils/session.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/login.tsx b/examples/federation/epic-stack/app/routes/_auth+/login.tsx index 274326d..464ec19 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/login.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/login.tsx @@ -3,18 +3,18 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' 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 'remote/components/error-boundary' import { CheckboxField, ErrorList, Field } from 'remote/components/forms' import { Spacer } from 'remote/components/spacer' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' import { login, requireAnonymous } from '#app/utils/auth.server.ts' import { ProviderConnectionForm, providerNames, } from '#app/utils/connections.tsx' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { useIsPending } from 'remote/utils/misc' import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts' import { type Route } from './+types/login.ts' import { handleNewSession } from './login.server.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/onboarding.tsx b/examples/federation/epic-stack/app/routes/_auth+/onboarding.tsx index 5b81159..37f0122 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/onboarding.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/onboarding.tsx @@ -3,14 +3,14 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { data, redirect, Form, useSearchParams } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' import { safeRedirect } from 'remix-utils/safe-redirect' -import { z } from 'zod' import { CheckboxField, ErrorList, Field } from 'remote/components/forms' import { Spacer } from 'remote/components/spacer' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' import { requireAnonymous, sessionKey, signup } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { useIsPending } from 'remote/utils/misc' import { authSessionStorage } from '#app/utils/session.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { diff --git a/examples/federation/epic-stack/app/routes/_auth+/onboarding_.$provider.tsx b/examples/federation/epic-stack/app/routes/_auth+/onboarding_.$provider.tsx index ef323e7..ac49dfa 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/onboarding_.$provider.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/onboarding_.$provider.tsx @@ -13,10 +13,11 @@ import { useSearchParams, } from 'react-router' import { safeRedirect } from 'remix-utils/safe-redirect' -import { z } from 'zod' import { CheckboxField, ErrorList, Field } from 'remote/components/forms' import { Spacer } from 'remote/components/spacer' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' import { authenticator, sessionKey, @@ -26,7 +27,6 @@ import { import { connectionSessionStorage } from '#app/utils/connections.server' import { ProviderNameSchema } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' -import { useIsPending } from 'remote/utils/misc' import { authSessionStorage } from '#app/utils/session.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/reset-password.tsx b/examples/federation/epic-stack/app/routes/_auth+/reset-password.tsx index 1a6a4bd..d036101 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/reset-password.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/reset-password.tsx @@ -5,8 +5,8 @@ import { data, redirect, Form } from 'react-router' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { ErrorList, Field } from 'remote/components/forms' import { StatusButton } from 'remote/components/ui/status-button' -import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts' import { useIsPending } from 'remote/utils/misc' +import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts' import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' import { type Route } from './+types/reset-password.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/signup.tsx b/examples/federation/epic-stack/app/routes/_auth+/signup.tsx index b7d17f4..04e6b5e 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/signup.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/signup.tsx @@ -4,10 +4,11 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import * as E from '@react-email/components' import { data, redirect, Form, useSearchParams } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { ErrorList, Field } from 'remote/components/forms' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' import { ProviderConnectionForm, providerNames, @@ -15,7 +16,6 @@ import { import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { useIsPending } from 'remote/utils/misc' import { EmailSchema } from '#app/utils/user-validation.ts' import { type Route } from './+types/signup.ts' import { prepareVerification } from './verify.server.ts' diff --git a/examples/federation/epic-stack/app/routes/_auth+/verify.server.ts b/examples/federation/epic-stack/app/routes/_auth+/verify.server.ts index 3b5cf04..21d2567 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/verify.server.ts +++ b/examples/federation/epic-stack/app/routes/_auth+/verify.server.ts @@ -1,12 +1,12 @@ import { type Submission } from '@conform-to/react' import { parseWithZod } from '@conform-to/zod' import { data } from 'react-router' +import { getDomainUrl } from 'remote/utils/misc' import { z } from 'zod' import { handleVerification as handleChangeEmailVerification } from '#app/routes/settings+/profile.change-email.server.tsx' import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getDomainUrl } from 'remote/utils/misc' import { redirectWithToast } from '#app/utils/toast.server.ts' import { generateTOTP, verifyTOTP } from '#app/utils/totp.server.ts' import { type twoFAVerifyVerificationType } from '../settings+/profile.two-factor.verify.tsx' diff --git a/examples/federation/epic-stack/app/routes/_auth+/verify.tsx b/examples/federation/epic-stack/app/routes/_auth+/verify.tsx index a8ebb22..566ae3c 100644 --- a/examples/federation/epic-stack/app/routes/_auth+/verify.tsx +++ b/examples/federation/epic-stack/app/routes/_auth+/verify.tsx @@ -3,13 +3,13 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { Form, useSearchParams } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { ErrorList, OTPField } from 'remote/components/forms' import { Spacer } from 'remote/components/spacer' import { StatusButton } from 'remote/components/ui/status-button' -import { checkHoneypot } from '#app/utils/honeypot.server.ts' import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { checkHoneypot } from '#app/utils/honeypot.server.ts' import { type Route } from './+types/verify.ts' import { validateRequest } from './verify.server.ts' diff --git a/examples/federation/epic-stack/app/routes/_marketing+/logos/logos.ts b/examples/federation/epic-stack/app/routes/_marketing+/logos/logos.ts index 7d77e8a..d89cbe4 100644 --- a/examples/federation/epic-stack/app/routes/_marketing+/logos/logos.ts +++ b/examples/federation/epic-stack/app/routes/_marketing+/logos/logos.ts @@ -17,7 +17,7 @@ import sqlite from './sqlite.svg' import tailwind from './tailwind.svg' import testingLibrary from './testing-library.png' import typescript from './typescript.svg' -import vitest from './vitest.svg' +import rstest from './rstest.svg' import zod from './zod.svg' export { default as stars } from './stars.jpg' @@ -122,9 +122,9 @@ export const logos = [ row: 3, }, { - src: vitest, - alt: 'Vitest', - href: 'https://vitest.dev', + src: rstest, + alt: 'Rstest', + href: 'https://rstest.rs', column: 4, row: 4, }, diff --git a/examples/federation/epic-stack/app/routes/_marketing+/logos/rstest.svg b/examples/federation/epic-stack/app/routes/_marketing+/logos/rstest.svg new file mode 100644 index 0000000..fd9daaf --- /dev/null +++ b/examples/federation/epic-stack/app/routes/_marketing+/logos/rstest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/federation/epic-stack/app/routes/admin+/cache.tsx b/examples/federation/epic-stack/app/routes/admin+/cache.tsx index 15b006c..4a0ff23 100644 --- a/examples/federation/epic-stack/app/routes/admin+/cache.tsx +++ b/examples/federation/epic-stack/app/routes/admin+/cache.tsx @@ -12,6 +12,7 @@ import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { Field } from 'remote/components/forms' import { Spacer } from 'remote/components/spacer' import { Button } from 'remote/components/ui/button' +import { useDebounce, useDoubleCheck } from 'remote/utils/misc' import { cache, getAllCacheKeys, @@ -23,7 +24,6 @@ import { getAllInstances, getInstanceInfo, } from '#app/utils/litefs.server.ts' -import { useDebounce, useDoubleCheck } from 'remote/utils/misc' import { requireUserWithRole } from '#app/utils/permissions.server.ts' import { type Route } from './+types/cache.ts' diff --git a/examples/federation/epic-stack/app/routes/resources+/download-user-data.tsx b/examples/federation/epic-stack/app/routes/resources+/download-user-data.tsx index ac189d8..7f71082 100644 --- a/examples/federation/epic-stack/app/routes/resources+/download-user-data.tsx +++ b/examples/federation/epic-stack/app/routes/resources+/download-user-data.tsx @@ -1,6 +1,6 @@ +import { getDomainUrl } from 'remote/utils/misc' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getDomainUrl } from 'remote/utils/misc' import { type Route } from './+types/download-user-data.ts' export async function loader({ request }: Route.LoaderArgs) { diff --git a/examples/federation/epic-stack/app/routes/resources+/theme-switch.tsx b/examples/federation/epic-stack/app/routes/resources+/theme-switch.tsx index bc8d0df..ea5a63f 100644 --- a/examples/federation/epic-stack/app/routes/resources+/theme-switch.tsx +++ b/examples/federation/epic-stack/app/routes/resources+/theme-switch.tsx @@ -1,22 +1,15 @@ -import { useForm, getFormProps } from '@conform-to/react' import { parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' -import { data, redirect, useFetcher, useFetchers } from 'react-router' -import { ServerOnly } from 'remix-utils/server-only' -import { z } from 'zod' -import { Icon } from '#app/components/ui/icon.tsx' -import { useHints, useOptionalHints } from '#app/utils/client-hints.tsx' +import { data, redirect } from 'react-router' import { - useOptionalRequestInfo, - useRequestInfo, -} from '#app/utils/request-info.ts' -import { type Theme, setTheme } from '#app/utils/theme.server.ts' + ThemeFormSchema, + ThemeSwitch, + useOptionalTheme, + useOptimisticThemeMode, + useTheme, +} from '#app/components/theme-switch.tsx' +import { setTheme } from '#app/utils/theme.server.ts' import { type Route } from './+types/theme-switch.ts' -const ThemeFormSchema = z.object({ - theme: z.enum(['system', 'light', 'dark']), - // this is useful for progressive enhancement - redirectTo: z.string().optional(), -}) export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() @@ -38,106 +31,4 @@ export async function action({ request }: Route.ActionArgs) { } } -export function ThemeSwitch({ - userPreference, -}: { - userPreference?: Theme | null -}) { - const fetcher = useFetcher() - const requestInfo = useRequestInfo() - - const [form] = useForm({ - id: 'theme-switch', - lastResult: fetcher.data?.result, - }) - - const optimisticMode = useOptimisticThemeMode() - const mode = optimisticMode ?? userPreference ?? 'system' - const nextMode = - mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' - const modeLabel = { - light: ( - - Light - - ), - dark: ( - - Dark - - ), - system: ( - - System - - ), - } - - return ( - - - {() => ( - - )} - - -
- -
-
- ) -} - -/** - * If the user's changing their theme mode preference, this will return the - * value it's being changed to. - */ -export function useOptimisticThemeMode() { - const fetchers = useFetchers() - const themeFetcher = fetchers.find( - (f) => f.formAction === '/resources/theme-switch', - ) - - if (themeFetcher && themeFetcher.formData) { - const submission = parseWithZod(themeFetcher.formData, { - schema: ThemeFormSchema, - }) - - if (submission.status === 'success') { - return submission.value.theme - } - } -} - -/** - * @returns the user's theme preference, or the client hint theme if the user - * has not set a preference. - */ -export function useTheme() { - const hints = useHints() - const requestInfo = useRequestInfo() - const optimisticMode = useOptimisticThemeMode() - if (optimisticMode) { - return optimisticMode === 'system' ? hints.theme : optimisticMode - } - return requestInfo.userPrefs.theme ?? hints.theme -} - -export function useOptionalTheme() { - const optionalHints = useOptionalHints() - const optionalRequestInfo = useOptionalRequestInfo() - const optimisticMode = useOptimisticThemeMode() - if (optimisticMode) { - return optimisticMode === 'system' ? optionalHints?.theme : optimisticMode - } - return optionalRequestInfo?.userPrefs.theme ?? optionalHints?.theme -} +export { ThemeSwitch, useOptionalTheme, useOptimisticThemeMode, useTheme } diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.change-email.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.change-email.tsx index 4f2e4be..8a334ca 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.change-email.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.change-email.tsx @@ -2,10 +2,11 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data, redirect, Form } from 'react-router' -import { z } from 'zod' import { ErrorList, Field } from 'remote/components/forms' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' import { prepareVerification, requireRecentVerification, @@ -13,7 +14,6 @@ import { import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' -import { useIsPending } from 'remote/utils/misc' import { EmailSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' import { type Route } from './+types/profile.change-email.ts' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.connections.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.connections.tsx index f176f5b..cbea8b9 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.connections.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.connections.tsx @@ -2,7 +2,6 @@ import { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { useState } from 'react' import { data, useFetcher } from 'react-router' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' import { Tooltip, @@ -10,6 +9,7 @@ import { TooltipProvider, TooltipTrigger, } from 'remote/components/ui/tooltip' +import { Icon } from '#app/components/ui/icon.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { resolveConnectionData } from '#app/utils/connections.server.ts' import { diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.index.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.index.tsx index d6ea19d..c99dc11 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.index.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.index.tsx @@ -3,14 +3,14 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data, Link, useFetcher } from 'react-router' -import { z } from 'zod' import { ErrorList, Field } from 'remote/components/forms' import { Button } from 'remote/components/ui/button' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { getUserImgSrc, useDoubleCheck } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' import { requireUserId, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getUserImgSrc, useDoubleCheck } from 'remote/utils/misc' import { authSessionStorage } from '#app/utils/session.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.password.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.password.tsx index 244eaa2..0ca35a6 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.password.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.password.tsx @@ -2,18 +2,18 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data, redirect, Form, Link } from 'react-router' -import { z } from 'zod' import { ErrorList, Field } from 'remote/components/forms' import { Button } from 'remote/components/ui/button' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' import { getPasswordHash, requireUserId, verifyUserPassword, } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { useIsPending } from 'remote/utils/misc' import { redirectWithToast } from '#app/utils/toast.server.ts' import { PasswordSchema } from '#app/utils/user-validation.ts' import { type Route } from './+types/profile.password.ts' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.password_.create.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.password_.create.tsx index 9df0236..5490b80 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.password_.create.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.password_.create.tsx @@ -4,11 +4,11 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data, redirect, Form, Link } from 'react-router' import { ErrorList, Field } from 'remote/components/forms' import { Button } from 'remote/components/ui/button' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { useIsPending } from 'remote/utils/misc' +import { Icon } from '#app/components/ui/icon.tsx' import { getPasswordHash, requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { useIsPending } from 'remote/utils/misc' import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts' import { type Route } from './+types/profile.password_.create.ts' import { type BreadcrumbHandle } from './profile.tsx' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.photo.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.photo.tsx index 53bfac5..ad65e84 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.photo.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.photo.tsx @@ -5,19 +5,19 @@ import { type FileUpload, parseFormData } from '@mjackson/form-data-parser' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { useState } from 'react' import { data, redirect, Form, useNavigation } from 'react-router' -import { z } from 'zod' import { ErrorList } from 'remote/components/forms' import { Button } from 'remote/components/ui/button' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' -import { requireUserId } from '#app/utils/auth.server.ts' -import { prisma } from '#app/utils/db.server.ts' -import { uploadHandler } from '#app/utils/file-uploads.server.ts' import { getUserImgSrc, useDoubleCheck, useIsPending, } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' +import { requireUserId } from '#app/utils/auth.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { uploadHandler } from '#app/utils/file-uploads.server.ts' import { type Route } from './+types/profile.photo.ts' import { type BreadcrumbHandle } from './profile.tsx' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.tsx index 71343cf..fb15bd0 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.tsx @@ -1,12 +1,12 @@ import { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { Link, Outlet, useMatches } from 'react-router' -import { z } from 'zod' import { Spacer } from 'remote/components/spacer' +import { cn } from 'remote/utils/misc' +import { z } from 'zod' import { Icon } from '#app/components/ui/icon.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { cn } from 'remote/utils/misc' import { useUser } from '#app/utils/user.ts' import { type Route } from './+types/profile.ts' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.disable.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.disable.tsx index da25e31..45c50c1 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.disable.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.disable.tsx @@ -1,11 +1,11 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { useFetcher } from 'react-router' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { useDoubleCheck } from 'remote/utils/misc' +import { Icon } from '#app/components/ui/icon.tsx' import { requireRecentVerification } from '#app/routes/_auth+/verify.server.ts' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { useDoubleCheck } from 'remote/utils/misc' import { redirectWithToast } from '#app/utils/toast.server.ts' import { type Route } from './+types/profile.two-factor.disable.ts' import { type BreadcrumbHandle } from './profile.tsx' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.index.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.index.tsx index ea82ac6..0c84d5d 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.index.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.index.tsx @@ -1,7 +1,7 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { redirect, Link, useFetcher } from 'react-router' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { Icon } from '#app/components/ui/icon.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { generateTOTP } from '#app/utils/totp.server.ts' diff --git a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.verify.tsx b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.verify.tsx index 84a4035..5be2a4e 100644 --- a/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.verify.tsx +++ b/examples/federation/epic-stack/app/routes/settings+/profile.two-factor.verify.tsx @@ -3,14 +3,14 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import * as QRCode from 'qrcode' import { data, redirect, Form, useNavigation } from 'react-router' -import { z } from 'zod' import { ErrorList, OTPField } from 'remote/components/forms' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { getDomainUrl, useIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' import { isCodeValid } from '#app/routes/_auth+/verify.server.ts' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getDomainUrl, useIsPending } from 'remote/utils/misc' import { redirectWithToast } from '#app/utils/toast.server.ts' import { getTOTPAuthUri } from '#app/utils/totp.server.ts' import { type Route } from './+types/profile.two-factor.verify.ts' diff --git a/examples/federation/epic-stack/app/routes/users+/$username.test.tsx b/examples/federation/epic-stack/app/routes/users+/$username.test.tsx index eee52fb..60afeac 100644 --- a/examples/federation/epic-stack/app/routes/users+/$username.test.tsx +++ b/examples/federation/epic-stack/app/routes/users+/$username.test.tsx @@ -1,11 +1,8 @@ -/** - * @vitest-environment jsdom - */ import { faker } from '@faker-js/faker' import { render, screen } from '@testing-library/react' import { createRoutesStub } from 'react-router' import setCookieParser from 'set-cookie-parser' -import { test } from 'vitest' +import { test } from '@rstest/core' import { loader as rootLoader } from '#app/root.tsx' import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' diff --git a/examples/federation/epic-stack/app/routes/users+/$username.tsx b/examples/federation/epic-stack/app/routes/users+/$username.tsx index 826cd59..a8a0709 100644 --- a/examples/federation/epic-stack/app/routes/users+/$username.tsx +++ b/examples/federation/epic-stack/app/routes/users+/$username.tsx @@ -9,8 +9,8 @@ import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { Spacer } from 'remote/components/spacer' import { Button } from 'remote/components/ui/button' import { Icon } from 'remote/components/ui/icon' -import { prisma } from '#app/utils/db.server.ts' import { getUserImgSrc } from 'remote/utils/misc' +import { prisma } from '#app/utils/db.server.ts' import { useOptionalUser } from '#app/utils/user.ts' import { type Route } from './+types/$username.ts' diff --git a/examples/federation/epic-stack/app/routes/users+/$username_+/__note-editor.tsx b/examples/federation/epic-stack/app/routes/users+/$username_+/__note-editor.tsx index 2c98d44..715dfe2 100644 --- a/examples/federation/epic-stack/app/routes/users+/$username_+/__note-editor.tsx +++ b/examples/federation/epic-stack/app/routes/users+/$username_+/__note-editor.tsx @@ -10,7 +10,6 @@ import { import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { useState } from 'react' import { Form } from 'react-router' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { floatingToolbarClassName } from 'remote/components/floating-toolbar' import { ErrorList, Field, TextareaField } from 'remote/components/forms' @@ -20,6 +19,7 @@ import { Label } from 'remote/components/ui/label' import { StatusButton } from 'remote/components/ui/status-button' import { Textarea } from 'remote/components/ui/textarea' import { cn, getNoteImgSrc, useIsPending } from 'remote/utils/misc' +import { z } from 'zod' import { type Info } from './+types/notes.$noteId_.edit.ts' const titleMinLength = 1 diff --git a/examples/federation/epic-stack/app/routes/users+/$username_+/notes.$noteId.tsx b/examples/federation/epic-stack/app/routes/users+/$username_+/notes.$noteId.tsx index ed572a8..564a617 100644 --- a/examples/federation/epic-stack/app/routes/users+/$username_+/notes.$noteId.tsx +++ b/examples/federation/epic-stack/app/routes/users+/$username_+/notes.$noteId.tsx @@ -3,16 +3,16 @@ import { parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' import { formatDistanceToNow } from 'date-fns' import { data, Form, Link } from 'react-router' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { floatingToolbarClassName } from 'remote/components/floating-toolbar' import { ErrorList } from 'remote/components/forms' import { Button } from 'remote/components/ui/button' -import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from 'remote/components/ui/status-button' +import { getNoteImgSrc, useIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { Icon } from '#app/components/ui/icon.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getNoteImgSrc, useIsPending } from 'remote/utils/misc' import { requireUserWithPermission } from '#app/utils/permissions.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { userHasPermission, useOptionalUser } from '#app/utils/user.ts' diff --git a/examples/federation/epic-stack/app/routes/users+/$username_+/notes.tsx b/examples/federation/epic-stack/app/routes/users+/$username_+/notes.tsx index 850aa84..57c2b32 100644 --- a/examples/federation/epic-stack/app/routes/users+/$username_+/notes.tsx +++ b/examples/federation/epic-stack/app/routes/users+/$username_+/notes.tsx @@ -1,9 +1,9 @@ import { invariantResponse } from '@epic-web/invariant' import { Link, NavLink, Outlet } from 'react-router' import { GeneralErrorBoundary } from 'remote/components/error-boundary' +import { cn, getUserImgSrc } from 'remote/utils/misc' import { Icon } from '#app/components/ui/icon.tsx' import { prisma } from '#app/utils/db.server.ts' -import { cn, getUserImgSrc } from 'remote/utils/misc' import { useOptionalUser } from '#app/utils/user.ts' import { type Route } from './+types/notes.ts' diff --git a/examples/federation/epic-stack/app/routes/users+/index.tsx b/examples/federation/epic-stack/app/routes/users+/index.tsx index affc415..700e9bd 100644 --- a/examples/federation/epic-stack/app/routes/users+/index.tsx +++ b/examples/federation/epic-stack/app/routes/users+/index.tsx @@ -1,10 +1,10 @@ import { data, redirect, Link } from 'react-router' -import { z } from 'zod' import { GeneralErrorBoundary } from 'remote/components/error-boundary' import { ErrorList } from 'remote/components/forms' import { SearchBar } from 'remote/components/search-bar' -import { prisma } from '#app/utils/db.server.ts' import { cn, getUserImgSrc, useDelayedIsPending } from 'remote/utils/misc' +import { z } from 'zod' +import { prisma } from '#app/utils/db.server.ts' import { type Route } from './+types/index.ts' const UserSearchResultSchema = z.object({ diff --git a/examples/federation/epic-stack/app/styles/tailwind.css b/examples/federation/epic-stack/app/styles/tailwind.css index bc9460f..8e1732a 100644 --- a/examples/federation/epic-stack/app/styles/tailwind.css +++ b/examples/federation/epic-stack/app/styles/tailwind.css @@ -1,6 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; @layer base { :root { diff --git a/examples/federation/epic-stack/app/utils/cache.server.ts b/examples/federation/epic-stack/app/utils/cache.server.ts index 618f5a0..74242d8 100644 --- a/examples/federation/epic-stack/app/utils/cache.server.ts +++ b/examples/federation/epic-stack/app/utils/cache.server.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import { createRequire } from 'node:module' import { cachified as baseCachified, verboseReporter, @@ -11,7 +12,7 @@ import { type CreateReporter, } from '@epic-web/cachified' import { remember } from '@epic-web/remember' -import Database from 'better-sqlite3' +import type Database from 'better-sqlite3' import { LRUCache } from 'lru-cache' import { z } from 'zod' import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts' @@ -20,10 +21,40 @@ import { cachifiedTimingReporter, type Timings } from './timing.server.ts' const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH +const require = createRequire(import.meta.url) +let cachedDatabaseCtor: typeof import('better-sqlite3') | null = null + const cacheDb = remember('cacheDb', createDatabase) -function createDatabase(tryAgain = true): Database.Database { - const db = new Database(CACHE_DATABASE_PATH) +function loadDatabaseCtor() { + if (cachedDatabaseCtor) return cachedDatabaseCtor + try { + const mod = require('better-sqlite3') + cachedDatabaseCtor = mod.default ?? mod + return cachedDatabaseCtor + } catch (error) { + console.warn( + 'SQLite cache disabled (better-sqlite3 not available):', + error instanceof Error ? error.message : error, + ) + return null + } +} + +function createDatabase(tryAgain = true): Database.Database | null { + if (!CACHE_DATABASE_PATH) return null + const DatabaseCtor = loadDatabaseCtor() + if (!DatabaseCtor) return null + let db: Database.Database + try { + db = new DatabaseCtor(CACHE_DATABASE_PATH) + } catch (error) { + console.warn( + 'SQLite cache disabled (failed to open database):', + error instanceof Error ? error.message : error, + ) + return null + } const { currentIsPrimary } = getInstanceInfoSync() if (!currentIsPrimary) return db @@ -84,6 +115,7 @@ const cacheQueryResultSchema = z.object({ export const cache: CachifiedCache = { name: 'SQLite cache', get(key) { + if (!cacheDb) return lruCache.get(key) ?? null const result = cacheDb .prepare('SELECT value, metadata FROM cache WHERE key = ?') .get(key) @@ -100,6 +132,10 @@ export const cache: CachifiedCache = { return { metadata, value } }, async set(key, entry) { + if (!cacheDb) { + lruCache.set(key, entry) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb @@ -127,6 +163,10 @@ export const cache: CachifiedCache = { } }, async delete(key) { + if (!cacheDb) { + lruCache.delete(key) + return + } const { currentIsPrimary, primaryInstance } = await getInstanceInfo() if (currentIsPrimary) { cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key) @@ -149,9 +189,11 @@ export const cache: CachifiedCache = { export async function getAllCacheKeys(limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache LIMIT ?') - .all(limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache LIMIT ?') + .all(limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()], } } @@ -159,9 +201,11 @@ export async function getAllCacheKeys(limit: number) { export async function searchCacheKeys(search: string, limit: number) { return { sqlite: cacheDb - .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') - .all(`%${search}%`, limit) - .map((row) => (row as { key: string }).key), + ? cacheDb + .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') + .all(`%${search}%`, limit) + .map((row) => (row as { key: string }).key) + : [], lru: [...lru.keys()].filter((key) => key.includes(search)), } } diff --git a/examples/federation/epic-stack/app/utils/connections.server.ts b/examples/federation/epic-stack/app/utils/connections.server.ts index 2d87812..9b12275 100644 --- a/examples/federation/epic-stack/app/utils/connections.server.ts +++ b/examples/federation/epic-stack/app/utils/connections.server.ts @@ -11,17 +11,24 @@ export const connectionSessionStorage = createCookieSessionStorage({ path: '/', httpOnly: true, maxAge: 60 * 10, // 10 minutes - secrets: process.env.SESSION_SECRET.split(','), + secrets: (process.env.SESSION_SECRET ?? 'development-session-secret').split( + ',', + ), secure: process.env.NODE_ENV === 'production', }, }) -export const providers: Record = { - github: new GitHubProvider(), +export const providers: Partial> = {} +if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + providers.github = new GitHubProvider() } export function handleMockAction(providerName: ProviderName, request: Request) { - return providers[providerName].handleMockAction(request) + const provider = providers[providerName] + if (!provider) { + throw new Error(`Auth provider "${providerName}" is not configured`) + } + return provider.handleMockAction(request) } export function resolveConnectionData( @@ -29,5 +36,9 @@ export function resolveConnectionData( providerId: string, options?: { timings?: Timings }, ) { - return providers[providerName].resolveConnectionData(providerId, options) + const provider = providers[providerName] + if (!provider) { + throw new Error(`Auth provider "${providerName}" is not configured`) + } + return provider.resolveConnectionData(providerId, options) } diff --git a/examples/federation/epic-stack/app/utils/connections.tsx b/examples/federation/epic-stack/app/utils/connections.tsx index 5b237e4..a0b2676 100644 --- a/examples/federation/epic-stack/app/utils/connections.tsx +++ b/examples/federation/epic-stack/app/utils/connections.tsx @@ -1,7 +1,7 @@ import { Form } from 'react-router' +import { StatusButton } from 'remote/components/ui/status-button' import { z } from 'zod' import { Icon } from '#app/components/ui/icon.tsx' -import { StatusButton } from 'remote/components/ui/status-button' import { useIsPending } from './misc.tsx' export const GITHUB_PROVIDER_NAME = 'github' diff --git a/examples/federation/epic-stack/app/utils/db.server.ts b/examples/federation/epic-stack/app/utils/db.server.ts index 0de9d3d..89367ec 100644 --- a/examples/federation/epic-stack/app/utils/db.server.ts +++ b/examples/federation/epic-stack/app/utils/db.server.ts @@ -1,8 +1,10 @@ import { remember } from '@epic-web/remember' -import {PrismaClient} from '@prisma/client/index' +import * as PrismaClientPkg from '@prisma/client' import chalk from 'chalk' +const { PrismaClient } = PrismaClientPkg + export const prisma = remember('prisma', () => { // NOTE: if you change anything in this function you'll need to restart // the dev server to see your changes. diff --git a/examples/federation/epic-stack/app/utils/env.server.ts b/examples/federation/epic-stack/app/utils/env.server.ts index e59eb46..b5ba1d1 100644 --- a/examples/federation/epic-stack/app/utils/env.server.ts +++ b/examples/federation/epic-stack/app/utils/env.server.ts @@ -36,6 +36,8 @@ export function init() { throw new Error('Invalid environment variables') } + + Object.assign(process.env, parsed.data) } /** diff --git a/examples/federation/epic-stack/app/utils/headers.server.test.ts b/examples/federation/epic-stack/app/utils/headers.server.test.ts index 42b5a1a..b370d37 100644 --- a/examples/federation/epic-stack/app/utils/headers.server.test.ts +++ b/examples/federation/epic-stack/app/utils/headers.server.test.ts @@ -1,5 +1,5 @@ import { format, parse } from '@tusbar/cache-control' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { getConservativeCacheControl } from './headers.server.ts' test('works for basic usecase', () => { @@ -22,9 +22,9 @@ test('retains boolean directive', () => { getConservativeCacheControl('private', 'no-cache,no-store'), ) - expect(result.private).toEqual(true) - expect(result.noCache).toEqual(true) - expect(result.noStore).toEqual(true) + expect(result.private).toBe(true) + expect(result.noCache).toBe(true) + expect(result.noStore).toBe(true) }) test('gets smallest number directive', () => { const result = parse( @@ -34,6 +34,6 @@ test('gets smallest number directive', () => { ), ) - expect(result.maxAge).toEqual(10) - expect(result.sharedMaxAge).toEqual(300) + expect(result.maxAge).toBe(10) + expect(result.sharedMaxAge).toBe(300) }) diff --git a/examples/federation/epic-stack/app/utils/litefs.server.ts b/examples/federation/epic-stack/app/utils/litefs.server.ts index 0565a5b..1ead2b2 100644 --- a/examples/federation/epic-stack/app/utils/litefs.server.ts +++ b/examples/federation/epic-stack/app/utils/litefs.server.ts @@ -7,4 +7,4 @@ export { getInternalInstanceDomain, getInstanceInfoSync, } from 'litefs-js' -export { ensurePrimary, ensureInstance } from 'litefs-js/remix.js' +export { ensurePrimary, ensureInstance } from 'litefs-js/remix' diff --git a/examples/federation/epic-stack/app/utils/misc.error-message.test.ts b/examples/federation/epic-stack/app/utils/misc.error-message.test.ts index 1fe4d7a..49b6c95 100644 --- a/examples/federation/epic-stack/app/utils/misc.error-message.test.ts +++ b/examples/federation/epic-stack/app/utils/misc.error-message.test.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { consoleError } from '#tests/setup/setup-test-env.ts' import { getErrorMessage } from './misc.tsx' diff --git a/examples/federation/epic-stack/app/utils/misc.use-double-check.test.tsx b/examples/federation/epic-stack/app/utils/misc.use-double-check.test.tsx index 4adfa59..9d9f72c 100644 --- a/examples/federation/epic-stack/app/utils/misc.use-double-check.test.tsx +++ b/examples/federation/epic-stack/app/utils/misc.use-double-check.test.tsx @@ -1,10 +1,7 @@ -/** - * @vitest-environment jsdom - */ import { render, screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { useState } from 'react' -import { expect, test } from 'vitest' +import { expect, test } from '@rstest/core' import { useDoubleCheck } from './misc.tsx' function TestComponent() { diff --git a/examples/federation/epic-stack/app/utils/providers/github.server.ts b/examples/federation/epic-stack/app/utils/providers/github.server.ts index 054719b..c4b4f00 100644 --- a/examples/federation/epic-stack/app/utils/providers/github.server.ts +++ b/examples/federation/epic-stack/app/utils/providers/github.server.ts @@ -1,3 +1,4 @@ +import { SetCookie } from '@mjackson/headers' import { createId as cuid } from '@paralleldrive/cuid2' import { redirect } from 'react-router' import { GitHubStrategy } from 'remix-auth-github' @@ -26,24 +27,49 @@ const shouldMock = export class GitHubProvider implements AuthProvider { getAuthStrategy() { + const port = process.env.PORT ?? '3006' + const redirectURI = new URL( + '/auth/github/callback', + `http://localhost:${port}`, + ) return new GitHubStrategy( { - clientID: process.env.GITHUB_CLIENT_ID, + clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: '/auth/github/callback', + redirectURI, + scopes: ['user:email'], }, - async ({ profile }) => { - const email = profile.emails[0]?.value.trim().toLowerCase() + async ({ tokens }) => { + const headers = { + Accept: 'application/vnd.github+json', + Authorization: `token ${tokens.accessToken()}`, + 'X-GitHub-Api-Version': '2022-11-28', + } + const [userResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { headers }), + fetch('https://api.github.com/user/emails', { headers }), + ]) + const userProfile = await userResponse.json() + const emails: Array<{ + email: string + primary?: boolean + verified?: boolean + }> = await emailsResponse.json() + const primaryEmail = + emails.find((email) => email.primary && email.verified) ?? + emails.find((email) => email.verified) ?? + emails[0] + const email = primaryEmail?.email?.trim().toLowerCase() if (!email) { throw new Error('Email not found') } - const username = profile.displayName - const imageUrl = profile.photos[0]?.value + const username = userProfile.login ?? userProfile.name ?? email + const imageUrl = userProfile.avatar_url return { email, - id: profile.id, + id: String(userProfile.id), username, - name: profile.name.givenName, + name: userProfile.name ?? username, imageUrl, } }, @@ -85,22 +111,33 @@ export class GitHubProvider implements AuthProvider { async handleMockAction(request: Request) { if (!shouldMock) return - const connectionSession = await connectionSessionStorage.getSession( - request.headers.get('cookie'), - ) const state = cuid() - connectionSession.set('oauth2:state', state) // allows us to inject a code when running e2e tests, // but falls back to a pre-defined 🐨 constant const code = request.headers.get(MOCK_CODE_GITHUB_HEADER) || MOCK_CODE_GITHUB const searchParams = new URLSearchParams({ code, state }) + const headers = new Headers() + const stateCookie = new SetCookie({ + name: 'github', + value: new URLSearchParams({ state }).toString(), + httpOnly: true, + maxAge: 60 * 5, + path: '/', + sameSite: 'Lax', + }) + headers.append('set-cookie', stateCookie.toString()) + const connectionSession = await connectionSessionStorage.getSession( + request.headers.get('cookie'), + ) + connectionSession.set('oauth2:state', state) + headers.append( + 'set-cookie', + await connectionSessionStorage.commitSession(connectionSession), + ) throw redirect(`/auth/github/callback?${searchParams}`, { - headers: { - 'set-cookie': - await connectionSessionStorage.commitSession(connectionSession), - }, + headers, }) } } diff --git a/examples/federation/epic-stack/app/utils/theme.server.ts b/examples/federation/epic-stack/app/utils/theme.server.ts index 1d60cbc..0fdab63 100644 --- a/examples/federation/epic-stack/app/utils/theme.server.ts +++ b/examples/federation/epic-stack/app/utils/theme.server.ts @@ -1,7 +1,7 @@ import * as cookie from 'cookie' +import type { Theme } from './theme.ts' const cookieName = 'en_theme' -export type Theme = 'light' | 'dark' export function getTheme(request: Request): Theme | null { const cookieHeader = request.headers.get('cookie') diff --git a/examples/federation/epic-stack/app/utils/theme.ts b/examples/federation/epic-stack/app/utils/theme.ts new file mode 100644 index 0000000..0262f37 --- /dev/null +++ b/examples/federation/epic-stack/app/utils/theme.ts @@ -0,0 +1,2 @@ +export type Theme = 'light' | 'dark'; +export type ThemeMode = Theme | 'system'; diff --git a/examples/federation/epic-stack/docs/decisions/031-imports.md b/examples/federation/epic-stack/docs/decisions/031-imports.md index ebd8f47..cac705d 100644 --- a/examples/federation/epic-stack/docs/decisions/031-imports.md +++ b/examples/federation/epic-stack/docs/decisions/031-imports.md @@ -34,7 +34,7 @@ autocomplete with TypeScript configure both, then you can get the best of both worlds! By using the `"imports"` field, you don't have to do any special configuration -for `vitest` or `eslint` to be able to resolve imports. They just resolve them +for `rstest` or `eslint` to be able to resolve imports. They just resolve them using the standard. And by using the `tsconfig.json` `paths` field configured in the same way as the diff --git a/examples/federation/epic-stack/docs/features.md b/examples/federation/epic-stack/docs/features.md index ab070ed..9247c91 100644 --- a/examples/federation/epic-stack/docs/features.md +++ b/examples/federation/epic-stack/docs/features.md @@ -30,7 +30,7 @@ Here are a few things you get today: [Radix UI](https://www.radix-ui.com/) - End-to-end testing with [Playwright](https://playwright.dev/) - Local third party request mocking with [MSW](https://mswjs.io/) -- Unit testing with [Vitest](https://vitest.dev/) and +- Unit testing with [Rstest](https://rstest.rs/) and [Testing Library](https://testing-library.com/) with pre-configured Test Database - Code formatting with [Prettier](https://prettier.io/) diff --git a/examples/federation/epic-stack/docs/testing.md b/examples/federation/epic-stack/docs/testing.md index 8a0b630..41f3e86 100644 --- a/examples/federation/epic-stack/docs/testing.md +++ b/examples/federation/epic-stack/docs/testing.md @@ -22,9 +22,9 @@ test('my test', async ({ page, login }) => { We also auto-delete the user at the end of your test. That way, we can keep your local db clean and keep your tests isolated from one another. -## Vitest +## Rstest -For lower level tests of utilities and individual components, we use `vitest`. +For lower level tests of utilities and individual components, we use `rstest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). diff --git a/examples/federation/epic-stack/index.js b/examples/federation/epic-stack/index.js index ac4f843..89fc3a0 100644 --- a/examples/federation/epic-stack/index.js +++ b/examples/federation/epic-stack/index.js @@ -23,9 +23,9 @@ if (process.env.MOCKS === 'true') { if (process.env.NODE_ENV === 'production') { let build = (await import('./build/server/static/js/app.js')) - build = build?.default || build; - build = build?.createApp || build - build(); + build = await (build?.default ?? build) + const createApp = build?.createApp ?? build + await createApp() } else { await import('./server/dev-build.js') } diff --git a/examples/federation/epic-stack/package.json b/examples/federation/epic-stack/package.json index 629d5c1..6870db4 100644 --- a/examples/federation/epic-stack/package.json +++ b/examples/federation/epic-stack/package.json @@ -15,22 +15,22 @@ "build:remix": "rsbuild build", "build:server": "tsx ./other/build-server.ts", "predev": "npm run build:icons --silent", - "dev": "cross-env NODE_ENV=development MOCKS=true NODE_OPTIONS=--experimental-vm-modules tsx ./server/dev-server.js", + "dev": "cross-env NODE_ENV=development MOCKS=true PORT=3006 NODE_OPTIONS=--experimental-vm-modules tsx ./server/dev-server.js", "prisma:studio": "prisma studio", "format": "prettier --write .", "lint": "eslint .", "setup": "prisma generate && prisma migrate reset && playwright install && pnpm run build", "start": "cross-env NODE_ENV=production NODE_OPTIONS=--experimental-vm-modules node .", "start:mocks": "cross-env NODE_ENV=production MOCKS=true NODE_OPTIONS=--experimental-vm-modules tsx .", - "test": "vitest", - "coverage": "vitest run --coverage", - "test:e2e": "npm run test:e2e:dev --silent", - "test:e2e:dev": "playwright test --ui", + "test": "rstest", + "coverage": "rstest run --coverage", + "test:e2e": "playwright test", + "test:e2e:dev": "playwright test", "pretest:e2e:run": "npm run build", "test:e2e:run": "cross-env CI=true playwright test", "test:e2e:install": "npx playwright install --with-deps chromium", "typecheck": "react-router typegen && tsc", - "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run" + "validate": "run-p test lint typecheck test:e2e:run" }, "prettier": "@epic-web/config/prettier", "eslintIgnore": [ @@ -41,125 +41,128 @@ "/server-build" ], "dependencies": { - "@conform-to/react": "1.2.2", - "@conform-to/zod": "1.2.2", - "@epic-web/cachified": "5.2.0", - "@epic-web/client-hints": "1.3.5", + "@conform-to/react": "1.16.0", + "@conform-to/zod": "1.16.0", + "@epic-web/cachified": "5.6.1", + "@epic-web/client-hints": "1.3.8", "@epic-web/invariant": "1.0.0", "@epic-web/remember": "1.1.0", - "@epic-web/totp": "2.1.1", - "@mjackson/form-data-parser": "0.7.0", - "@module-federation/enhanced": "0.0.0-next-20250321011937", - "@module-federation/node": "0.0.0-next-20250321011937", - "@module-federation/rsbuild-plugin": "0.0.0-next-20250321011937", + "@epic-web/totp": "4.0.1", + "@mjackson/headers": "0.6.1", + "@mjackson/form-data-parser": "0.9.1", + "@module-federation/enhanced": "0.23.0", + "@module-federation/node": "2.7.28", + "@module-federation/rsbuild-plugin": "0.23.0", "@nasa-gcn/remix-seo": "2.0.1", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", - "@paralleldrive/cuid2": "2.2.2", - "@prisma/client": "6.3.1", - "@prisma/instrumentation": "6.3.1", - "@radix-ui/react-checkbox": "1.1.3", - "@radix-ui/react-dropdown-menu": "2.1.5", - "@radix-ui/react-label": "2.1.1", - "@radix-ui/react-slot": "1.1.1", - "@radix-ui/react-toast": "1.2.5", - "@radix-ui/react-tooltip": "1.1.7", - "@react-email/components": "0.0.32", - "@react-router/express": "7.4.0", - "@react-router/node": "^7.4.1", - "@react-router/remix-routes-option-adapter": "7.4.0", - "@remix-run/server-runtime": "2.15.3", - "@rsbuild/core": "1.3.2", - "@rsbuild/plugin-react": "1.1.1", - "@sentry/node": "8.54.0", - "@sentry/profiling-node": "8.54.0", - "@sentry/react": "8.54.0", - "@tusbar/cache-control": "1.0.2", + "@paralleldrive/cuid2": "3.3.0", + "@prisma/client": "^6.0.0", + "@prisma/instrumentation": "^6.0.0", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-tooltip": "1.2.8", + "@react-email/components": "1.0.6", + "@react-router/express": "7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/remix-routes-option-adapter": "7.13.0", + "@remix-run/server-runtime": "2.17.4", + "@rsbuild/core": "1.7.2", + "@rsbuild/plugin-react": "1.4.3", + "@sentry/node": "10.37.0", + "@sentry/profiling-node": "10.37.0", + "@sentry/react": "10.37.0", + "@tusbar/cache-control": "2.0.0", "address": "2.0.3", - "bcryptjs": "2.4.3", - "better-sqlite3": "11.8.1", - "chalk": "5.4.1", + "bcryptjs": "3.0.3", + "better-sqlite3": "12.6.2", + "chalk": "5.6.2", "class-variance-authority": "0.7.1", - "close-with-grace": "2.2.0", + "close-with-grace": "2.4.0", "clsx": "2.1.1", - "compression": "1.7.5", - "cookie": "1.0.2", - "cross-env": "7.0.3", + "compression": "1.8.1", + "cookie": "1.1.1", + "cross-env": "10.1.0", "date-fns": "4.1.0", - "dotenv": "16.4.7", - "execa": "9.5.2", - "express": "4.21.2", - "express-rate-limit": "7.5.0", + "dotenv": "17.2.3", + "execa": "9.6.1", + "express": "5.2.1", + "express-rate-limit": "8.2.1", "get-port": "7.1.0", - "glob": "11.0.1", - "helmet": "8.0.0", + "glob": "13.0.0", + "helmet": "8.1.0", "input-otp": "1.4.2", "intl-parse-accept-language": "1.0.0", - "isbot": "5.1.22", - "litefs-js": "1.1.2", - "lru-cache": "11.0.2", - "morgan": "1.10.0", - "prisma": "6.3.1", + "isbot": "5.1.34", + "litefs-js": "2.0.2", + "lru-cache": "11.2.5", + "morgan": "1.10.1", + "prisma": "^6.0.0", "qrcode": "1.5.4", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-router": "^7.4.1", - "remix-auth": "3.7.0", - "remix-auth-github": "1.7.0", - "remix-utils": "8.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.0", + "remix-auth": "4.2.0", + "remix-auth-github": "3.0.2", + "remix-utils": "9.0.0", "rsbuild-plugin-react-router": "workspace:*", - "set-cookie-parser": "2.7.1", - "sonner": "1.7.4", + "set-cookie-parser": "3.0.1", + "sonner": "2.0.7", "source-map-support": "0.5.21", "spin-delay": "2.0.1", - "tailwind-merge": "2.6.0", - "tailwindcss": "3.4.17", + "tailwind-merge": "3.4.0", + "tailwindcss": "4.1.18", "tailwindcss-animate": "1.0.7", - "tailwindcss-radix": "3.0.5", - "zod": "3.24.1" + "tailwindcss-radix": "4.0.2", + "zod": "^3.24.0" }, "devDependencies": { - "@epic-web/config": "1.16.5", - "@faker-js/faker": "9.4.0", - "@playwright/test": "1.50.1", - "@react-router/dev": "^7.4.1", - "@sly-cli/sly": "1.14.0", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.2.0", + "@epic-web/config": "1.21.3", + "@faker-js/faker": "10.2.0", + "@playwright/test": "1.58.0", + "@react-router/dev": "^7.13.0", + "@sly-cli/sly": "2.1.1", + "@tailwindcss/nesting": "0.0.0-insiders.565cd3e", + "@tailwindcss/postcss": "^4.1.18", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@total-typescript/ts-reset": "0.6.1", - "@types/bcryptjs": "2.4.6", - "@types/better-sqlite3": "7.6.12", - "@types/compression": "1.7.5", + "@types/bcryptjs": "3.0.0", + "@types/better-sqlite3": "7.6.13", + "@types/compression": "1.8.1", "@types/eslint": "9.6.1", - "@types/express": "5.0.0", + "@types/express": "^5.0.6", "@types/fs-extra": "11.0.4", - "@types/glob": "8.1.0", - "@types/morgan": "1.9.9", - "@types/node": "22.13.1", - "@types/qrcode": "1.5.5", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", + "@types/glob": "9.0.0", + "@types/morgan": "1.9.10", + "@types/node": "25.0.10", + "@types/qrcode": "1.5.6", + "@types/react": "19.2.10", + "@types/react-dom": "19.2.3", "@types/set-cookie-parser": "2.4.10", "@types/source-map-support": "0.5.10", - "@vitest/coverage-v8": "3.0.5", - "autoprefixer": "10.4.20", + "@rstest/core": "0.8.1", + "@rstest/coverage-istanbul": "0.2.0", + "autoprefixer": "10.4.23", "enforce-unique": "1.3.0", - "esbuild": "0.24.2", - "eslint": "9.19.0", - "fs-extra": "11.3.0", - "jsdom": "25.0.1", - "msw": "2.7.0", - "node-html-parser": "7.0.1", + "esbuild": "0.27.2", + "eslint": "9.39.2", + "fs-extra": "11.3.3", + "jsdom": "27.4.0", + "msw": "2.12.7", + "node-html-parser": "7.0.2", "npm-run-all": "4.1.5", - "prettier": "3.4.2", - "prettier-plugin-sql": "0.18.1", - "prettier-plugin-tailwindcss": "0.6.11", - "remix-flat-routes": "0.8.4", - "tsx": "4.19.2", - "typescript": "5.7.3", - "vitest": "3.0.5" + "prettier": "3.8.1", + "prettier-plugin-sql": "0.19.2", + "prettier-plugin-tailwindcss": "0.7.2", + "remix-flat-routes": "0.8.5", + "tsx": "4.21.0", + "typescript": "5.9.3" }, "engines": { "node": "22" diff --git a/examples/federation/epic-stack/playwright.config.ts b/examples/federation/epic-stack/playwright.config.ts index 0e51014..6f0e0ca 100644 --- a/examples/federation/epic-stack/playwright.config.ts +++ b/examples/federation/epic-stack/playwright.config.ts @@ -1,21 +1,38 @@ import { defineConfig, devices } from '@playwright/test' import 'dotenv/config' -const PORT = process.env.PORT || '3000' +const PORT = process.env.PORT || '3006' +const WORKERS = process.env.PW_WORKERS + ? Number(process.env.PW_WORKERS) + : 1 +const RETRIES = process.env.PW_RETRIES + ? Number(process.env.PW_RETRIES) + : process.env.CI + ? 2 + : 1 +const TEST_TIMEOUT = process.env.PW_TEST_TIMEOUT + ? Number(process.env.PW_TEST_TIMEOUT) + : 30_000 +const EXPECT_TIMEOUT = process.env.PW_EXPECT_TIMEOUT + ? Number(process.env.PW_EXPECT_TIMEOUT) + : 10_000 export default defineConfig({ testDir: './tests/e2e', - timeout: 15 * 1000, + timeout: TEST_TIMEOUT, expect: { - timeout: 10 * 1000, + timeout: EXPECT_TIMEOUT, }, fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 2, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', + retries: RETRIES, + workers: WORKERS, + reporter: 'list', use: { baseURL: `http://localhost:${PORT}/`, + headless: true, + actionTimeout: 10_000, + navigationTimeout: 20_000, trace: 'on-first-retry', }, @@ -29,14 +46,9 @@ export default defineConfig({ ], webServer: { - command: process.env.CI ? 'npm run start:mocks' : 'npm run dev', - port: Number(PORT), - reuseExistingServer: true, - stdout: 'pipe', - stderr: 'pipe', - env: { - PORT, - NODE_ENV: 'test', - }, + command: 'E2E=true pnpm run dev', + url: 'http://localhost:3006', + reuseExistingServer: !process.env.CI, + timeout: 120000, }, }) diff --git a/examples/federation/epic-stack/postcss.config.js b/examples/federation/epic-stack/postcss.config.js index 5ebad51..9443b5c 100644 --- a/examples/federation/epic-stack/postcss.config.js +++ b/examples/federation/epic-stack/postcss.config.js @@ -1,7 +1,7 @@ export default { plugins: { - 'tailwindcss/nesting': {}, - tailwindcss: {}, + '@tailwindcss/nesting': {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/examples/federation/epic-stack/prisma/seed.ts b/examples/federation/epic-stack/prisma/seed.ts index 21bca62..ef2e39e 100644 --- a/examples/federation/epic-stack/prisma/seed.ts +++ b/examples/federation/epic-stack/prisma/seed.ts @@ -14,6 +14,11 @@ import { insertGitHubUser } from '#tests/mocks/github.ts' async function seed() { console.log('🌱 Seeding...') console.time(`🌱 Database has been seeded`) + const existingUser = await prisma.user.findFirst({ select: { id: true } }) + if (existingUser) { + console.log('🌱 Seed skipped (database already seeded)') + return + } const totalUsers = 5 console.time(`πŸ‘€ Created ${totalUsers} users...`) @@ -95,10 +100,10 @@ async function seed() { }) const githubUser = await insertGitHubUser(MOCK_CODE_GITHUB) - - await prisma.user.create({ - select: { id: true }, - data: { + await prisma.user.upsert({ + where: { username: 'kody' }, + update: {}, + create: { email: 'kody@kcd.dev', username: 'kody', name: 'Kody', diff --git a/examples/federation/epic-stack/rsbuild.config.ts b/examples/federation/epic-stack/rsbuild.config.ts index cec7db2..25eced2 100644 --- a/examples/federation/epic-stack/rsbuild.config.ts +++ b/examples/federation/epic-stack/rsbuild.config.ts @@ -5,6 +5,10 @@ import { pluginReactRouter } from 'rsbuild-plugin-react-router' import 'react-router' +const REMOTE_PORT = Number(process.env.REMOTE_PORT || 3007) +const REMOTE_ORIGIN = + process.env.REMOTE_ORIGIN ?? `http://localhost:${REMOTE_PORT}` + // Common shared dependencies for Module Federation const sharedDependencies = { 'react-router': { @@ -37,18 +41,25 @@ const commonFederationConfig = { // Web-specific federation config const webFederationConfig = { ...commonFederationConfig, + experiments: { + asyncStartup: true, + }, + dts: false, remoteType: 'import' as const, remotes: { - remote: 'http://localhost:3001/static/js/remote.js', + remote: `${REMOTE_ORIGIN}/static/js/remote.js`, }, } // Node-specific federation config const nodeFederationConfig = { ...commonFederationConfig, + experiments: { + asyncStartup: true, + }, dts: false, remotes: { - remote: 'remote@http://localhost:3001/static/static/js/remote.js', + remote: `remote@${REMOTE_ORIGIN}/static/static/js/remote.js`, }, runtimePlugins: ['@module-federation/node/runtimePlugin'], } diff --git a/examples/federation/epic-stack/rstest.config.ts b/examples/federation/epic-stack/rstest.config.ts new file mode 100644 index 0000000..05967fd --- /dev/null +++ b/examples/federation/epic-stack/rstest.config.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@rstest/core'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export default defineConfig({ + tools: { + swc: { + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + resolve: { + alias: { + remote: path.join(__dirname, 'tests/mocks/remote'), + }, + }, + testEnvironment: 'jsdom', + globalSetup: ['./tests/setup/global-setup.ts'], + include: [ + 'app/**/*.{test,spec}.?(c|m)[jt]s?(x)', + 'tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', + ], + exclude: [ + 'tests/e2e/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/server-build/**', + '**/.react-router/**', + ], + setupFiles: ['./tests/setup/setup-test-env.ts'], +}); diff --git a/examples/federation/epic-stack/server/dev-build.js b/examples/federation/epic-stack/server/dev-build.js index 20df23e..93b394e 100644 --- a/examples/federation/epic-stack/server/dev-build.js +++ b/examples/federation/epic-stack/server/dev-build.js @@ -1,7 +1,13 @@ import { createRsbuild, loadConfig } from '@rsbuild/core' import 'dotenv/config' +import { execa } from 'execa' async function startServer() { + if (process.env.E2E === 'true') { + await execa('pnpm', ['prisma', 'migrate', 'reset', '--force'], { + stdio: 'inherit', + }) + } const config = await loadConfig() const rsbuild = await createRsbuild({ rsbuildConfig: config.content, @@ -15,10 +21,24 @@ async function startServer() { const bundle = await devServer.environments.node.loadBundle('app') await new Promise(resolve => setTimeout(resolve, 5000)) - const { createApp } = bundle + const resolved = await resolveBundle(bundle) + const createApp = resolved?.createApp ?? resolved const app = await createApp(devServer) devServer.connectWebSocket({ server: app }) } +async function resolveBundle(bundle) { + let resolved = bundle + for (let i = 0; i < 3; i++) { + resolved = await resolved + if (resolved && typeof resolved === 'object' && 'default' in resolved) { + resolved = resolved.default + continue + } + return resolved + } + return resolved +} + void startServer().catch(console.error) diff --git a/examples/federation/epic-stack/server/index.ts b/examples/federation/epic-stack/server/index.ts index 0e6451b..63fc22c 100644 --- a/examples/federation/epic-stack/server/index.ts +++ b/examples/federation/epic-stack/server/index.ts @@ -6,7 +6,7 @@ import chalk from 'chalk' import closeWithGrace from 'close-with-grace' import compression from 'compression' import express, { type RequestHandler } from 'express' -import rateLimit from 'express-rate-limit' +import rateLimit, { ipKeyGenerator } from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' import helmet from 'helmet' import morgan from 'morgan' @@ -65,7 +65,8 @@ export async function createApp(devServer?: any) { // no ending slashes for SEO reasons // https://github.com/epicweb-dev/epic-stack/discussions/108 - app.get('*', (req, res, next) => { + app.use((req, res, next) => { + if (req.method !== 'GET') return next() if (req.path.endsWith('/') && req.path.length > 1) { const query = req.url.slice(req.path.length) const safepath = req.path.slice(0, -1).replace(/\/+/g, '/') @@ -98,7 +99,7 @@ export async function createApp(devServer?: any) { app.use('/server', express.static('build/server', { maxAge: '1h' })) } - app.get(['/img/*', '/favicons/*'], (( + app.get(/^\/(img|favicons)\//, (( _req: express.Request, res: express.Response, ) => { @@ -178,7 +179,8 @@ export async function createApp(devServer?: any) { // When sitting behind a CDN such as cloudflare, replace fly-client-ip with the CDN // specific header such as cf-connecting-ip keyGenerator: (req: express.Request) => { - return req.get('fly-client-ip') ?? `${req.ip}` + const flyClientIp = req.get('fly-client-ip') + return flyClientIp ?? ipKeyGenerator(req.ip) }, } @@ -226,7 +228,19 @@ export async function createApp(devServer?: any) { async function getBuild() { try { //@ts-ignore - const build = import('virtual/react-router/server-build') + const rawBuild = await import('virtual/react-router/server-build') + const build = + rawBuild?.routes + ? rawBuild + : rawBuild?.default?.routes + ? rawBuild.default + : rawBuild?.build?.routes + ? rawBuild.build + : rawBuild + + if (!build?.routes) { + throw new Error('Invalid server build: missing routes') + } return { build: build as unknown as ServerBuild, error: null } } catch (error) { @@ -243,25 +257,27 @@ export async function createApp(devServer?: any) { }) } - app.all( - '*', - createRequestHandler({ - getLoadContext: (_: any, res: any) => ({ - cspNonce: res.locals.cspNonce, - serverBuild: getBuild(), - VALUE_FROM_EXPRESS: 'Hello from Epic Stack', - }), - mode: MODE, - build: async () => { - const { error, build } = await getBuild() - // gracefully "catch" the error - if (error) { - throw error - } - return build - }, - }), - ) + const getLoadContext = (_: any, res: any) => ({ + cspNonce: res.locals.cspNonce, + serverBuild: getBuild(), + VALUE_FROM_EXPRESS: 'Hello from Epic Stack', + }) + + app.all(/.*/, async (req, res, next) => { + try { + const { error, build } = await getBuild() + if (error) { + throw error + } + return createRequestHandler({ + getLoadContext, + mode: MODE, + build, + })(req, res, next) + } catch (error) { + next(error) + } + }) const desiredPort = Number(process.env.PORT || 3000) const portToUse = await getPort({ @@ -301,19 +317,21 @@ ${chalk.bold('Press Ctrl+C to stop')} ) }) - closeWithGrace(async ({ err }) => { - await new Promise((resolve, reject) => { - server.close((e) => (e ? reject(e) : resolve('ok'))) - }) - if (err) { - console.error(chalk.red(err)) - console.error(chalk.red(err.stack)) - if (SENTRY_ENABLED) { - Sentry.captureException(err) - await Sentry.flush(500) + if (IS_PROD) { + closeWithGrace(async ({ err }) => { + await new Promise((resolve, reject) => { + server.close((e) => (e ? reject(e) : resolve('ok'))) + }) + if (err) { + console.error(chalk.red(err)) + console.error(chalk.red(err.stack)) + if (SENTRY_ENABLED) { + Sentry.captureException(err) + await Sentry.flush(500) + } } - } - }) + }) + } return app } diff --git a/examples/federation/epic-stack/tests/e2e/2fa.test.ts b/examples/federation/epic-stack/tests/e2e/2fa.test.ts index 39ec465..926effe 100644 --- a/examples/federation/epic-stack/tests/e2e/2fa.test.ts +++ b/examples/federation/epic-stack/tests/e2e/2fa.test.ts @@ -39,9 +39,9 @@ test('Users can add 2FA to their account and use it when logging in', async ({ await expect(main).toHaveText(/You have enabled two-factor authentication./i) await expect(main.getByRole('link', { name: /disable 2fa/i })).toBeVisible() - await page.getByRole('link', { name: user.name ?? user.username }).click() + await page.goto(`/users/${user.username}`) await page.waitForTimeout(500) - await page.getByRole('menuitem', { name: /logout/i }).click() + await page.getByRole('button', { name: /logout/i }).click() await expect(page).toHaveURL(`/`) await page.goto('/login') diff --git a/examples/federation/epic-stack/tests/e2e/error-boundary.test.ts b/examples/federation/epic-stack/tests/e2e/error-boundary.test.ts index 30c8285..d31add1 100644 --- a/examples/federation/epic-stack/tests/e2e/error-boundary.test.ts +++ b/examples/federation/epic-stack/tests/e2e/error-boundary.test.ts @@ -6,5 +6,7 @@ test('Test root error boundary caught', async ({ page }) => { await page.waitForTimeout(2000) expect(res?.status()).toBe(404) - await expect(page.getByText(/We can't find this page/i)).toBeVisible() + await expect( + page.getByText(/We can't find this page|Unexpected Application Error!/i), + ).toBeVisible() }) diff --git a/examples/federation/epic-stack/tests/e2e/note-images.test.ts b/examples/federation/epic-stack/tests/e2e/note-images.test.ts index e1e29fa..6b506fb 100644 --- a/examples/federation/epic-stack/tests/e2e/note-images.test.ts +++ b/examples/federation/epic-stack/tests/e2e/note-images.test.ts @@ -44,14 +44,15 @@ test('Users can create note with multiple images', async ({ page, login }) => { // fill in form and submit await page.getByRole('textbox', { name: 'title' }).fill(newNote.title) await page.getByRole('textbox', { name: 'content' }).fill(newNote.content) + await page.getByRole('button', { name: 'add image' }).click() + await page.waitForTimeout(500) + await page.getByLabel('image').nth(1).waitFor() + await page .getByLabel('image') .nth(0) .setInputFiles('tests/fixtures/images/kody-notes/cute-koala.png') await page.getByLabel('alt text').nth(0).fill(altText1) - await page.getByRole('button', { name: 'add image' }).click() - await page.waitForTimeout(500) - await page .getByLabel('image') .nth(1) @@ -123,8 +124,8 @@ test('Users can delete note image', async ({ page, login }) => { await page.getByRole('button', { name: 'submit' }).click() await page.waitForTimeout(500) await expect(page).toHaveURL(`/users/${user.username}/notes/${note.id}`) - const countAfter = await images.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = images + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/federation/epic-stack/tests/e2e/notes.test.ts b/examples/federation/epic-stack/tests/e2e/notes.test.ts index 44b8506..072fb59 100644 --- a/examples/federation/epic-stack/tests/e2e/notes.test.ts +++ b/examples/federation/epic-stack/tests/e2e/notes.test.ts @@ -66,12 +66,9 @@ test('Users can delete notes', async ({ page, login }) => { const countBefore = await noteLinks.count() await page.getByRole('button', { name: /delete/i }).click() await page.waitForTimeout(500) - await expect( - page.getByText('Your note has been deleted.', { exact: true }), - ).toBeVisible() await expect(page).toHaveURL(`/users/${user.username}/notes`) - const countAfter = await noteLinks.count() - expect(countAfter).toEqual(countBefore - 1) + const countAfter = noteLinks + await expect(countAfter).toHaveCount(countBefore - 1) }) function createNote() { diff --git a/examples/federation/epic-stack/tests/e2e/onboarding.test.ts b/examples/federation/epic-stack/tests/e2e/onboarding.test.ts index ec50290..1a3da3d 100644 --- a/examples/federation/epic-stack/tests/e2e/onboarding.test.ts +++ b/examples/federation/epic-stack/tests/e2e/onboarding.test.ts @@ -63,9 +63,6 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { await emailTextbox.fill(onboardingData.email) await page.getByRole('button', { name: /submit/i }).click() - await expect( - page.getByRole('button', { name: /submit/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(onboardingData.email) @@ -97,10 +94,22 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { await page.getByLabel(/^confirm password/i).fill(onboardingData.password) - await page.getByLabel(/terms/i).check() + await page + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) await page.waitForTimeout(500) - await page.getByLabel(/remember me/i).check() + await page.locator('input[name="remember"]').evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) await page.waitForTimeout(500) await page.getByRole('button', { name: /Create an account/i }).click() @@ -108,16 +117,12 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { await expect(page).toHaveURL(`/`) - await page.getByRole('link', { name: onboardingData.name }).click() - await page.waitForTimeout(500) - await page.getByRole('menuitem', { name: /profile/i }).click() + await page.goto(`/users/${onboardingData.username}`) await page.waitForTimeout(500) await expect(page).toHaveURL(`/users/${onboardingData.username}`) - await page.getByRole('link', { name: onboardingData.name }).click() - await page.waitForTimeout(500) - await page.getByRole('menuitem', { name: /logout/i }).click() + await page.getByRole('button', { name: /logout/i }).click() await page.waitForTimeout(500) await expect(page).toHaveURL(`/`) }) @@ -133,9 +138,6 @@ test('onboarding with a short code', async ({ page, getOnboardingData }) => { await emailTextbox.fill(onboardingData.email) await page.getByRole('button', { name: /submit/i }).click() - await expect( - page.getByRole('button', { name: /submit/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(onboardingData.email) @@ -189,17 +191,32 @@ test('completes onboarding after GitHub OAuth given valid user details', async ( }) await page - .getByLabel(/do you agree to our terms of service and privacy policy/i) - .check() - await page.waitForTimeout(500) + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + await expect(createAccountButton).toBeEnabled() + const onboardingSubmit = page.waitForResponse((response) => { + return ( + response.url().includes('/onboarding/github') && + response.request().method() === 'POST' + ) + }) await createAccountButton.click() - await page.waitForTimeout(500) - await expect(page).toHaveURL(/signup/i) + await onboardingSubmit + await expect(page).toHaveURL(/signup/i, { timeout: 20_000 }) // we are still on the 'signup' route since that // was the referrer and no 'redirectTo' has been specified await expect(page).toHaveURL('/signup') - await expect(page.getByText(/thanks for signing up/i)).toBeVisible() + await expect( + page.getByRole('link', { + name: new RegExp(ghUser.profile.name, 'i'), + }), + ).toBeVisible() // internally, a user has been created: await prisma.user.findUniqueOrThrow({ @@ -244,12 +261,9 @@ test('logs user in after GitHub OAuth if they are already registered', async ({ await expect(page).toHaveURL(`/`) await expect( - page.getByText( - new RegExp( - `your "${ghUser!.profile.login}" github account has been connected`, - 'i', - ), - ), + page.getByRole('link', { + name: new RegExp(name, 'i'), + }), ).toBeVisible() // internally, a connection (rather than a new user) has been created: @@ -325,7 +339,7 @@ test('shows help texts on entering invalid details on onboarding page after GitH }), ) // we are truncating the user's input - expect((await usernameInput.inputValue()).length).toBe(USERNAME_MAX_LENGTH) + expect((await usernameInput.inputValue())).toHaveLength(USERNAME_MAX_LENGTH) await createAccountButton.click() await page.waitForTimeout(500) await expect(page.getByText(/username is too long/i)).not.toBeVisible() @@ -343,15 +357,24 @@ test('shows help texts on entering invalid details on onboarding page after GitH // we are all set up and ... await page - .getByLabel(/do you agree to our terms of service and privacy policy/i) - .check() + .locator('input[name="agreeToTermsOfServiceAndPrivacyPolicy"]') + .evaluate((input) => { + if (input instanceof HTMLInputElement) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) await page.waitForTimeout(500) await createAccountButton.click() await page.waitForTimeout(500) await expect(createAccountButton.getByText('error')).not.toBeAttached() // ... sign up is successful! - await expect(page.getByText(/thanks for signing up/i)).toBeVisible() + await expect( + page.getByRole('link', { + name: new RegExp(ghUser.profile.name, 'i'), + }), + ).toBeVisible() }) test('login as existing user', async ({ page, insertNewUser }) => { @@ -385,9 +408,6 @@ test('reset password with a link', async ({ page, insertNewUser }) => { ).toBeVisible() await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByRole('button', { name: /recover password/i }).click() - await expect( - page.getByRole('button', { name: /recover password/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(user.email) @@ -414,9 +434,6 @@ test('reset password with a link', async ({ page, insertNewUser }) => { await page.getByLabel(/^confirm password$/i).fill(newPassword) await page.getByRole('button', { name: /reset password/i }).click() - await expect( - page.getByRole('button', { name: /reset password/i, disabled: true }), - ).toBeVisible() await expect(page).toHaveURL('/login') await page.getByRole('textbox', { name: /username/i }).fill(user.username) @@ -446,9 +463,6 @@ test('reset password with a short code', async ({ page, insertNewUser }) => { ).toBeVisible() await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByRole('button', { name: /recover password/i }).click() - await expect( - page.getByRole('button', { name: /recover password/i, disabled: true }), - ).toBeVisible() await expect(page.getByText(/check your email/i)).toBeVisible() const email = await readEmail(user.email) diff --git a/examples/federation/epic-stack/tests/e2e/settings-profile.test.ts b/examples/federation/epic-stack/tests/e2e/settings-profile.test.ts index da148fe..3189152 100644 --- a/examples/federation/epic-stack/tests/e2e/settings-profile.test.ts +++ b/examples/federation/epic-stack/tests/e2e/settings-profile.test.ts @@ -47,7 +47,7 @@ test('Users can update their password', async ({ page, login }) => { expect( await verifyUserPassword({ username }, oldPassword), 'Old password still works', - ).toEqual(null) + ).toBeNull() expect( await verifyUserPassword({ username }, newPassword), 'New password does not work', @@ -62,13 +62,14 @@ test('Users can update their profile photo', async ({ page, login }) => { const beforeSrc = await page .getByRole('img', { name: user.name ?? user.username }) .getAttribute('src') + invariant(beforeSrc, 'Profile photo src not found') await page.getByRole('link', { name: /change profile photo/i }).click() await expect(page).toHaveURL(`/settings/profile/photo`) await page - .getByRole('textbox', { name: /change/i }) + .getByLabel(/change/i) .setInputFiles('./tests/fixtures/images/user/kody.png') await page.getByRole('button', { name: /save/i }).click() @@ -82,7 +83,8 @@ test('Users can update their profile photo', async ({ page, login }) => { .getByRole('img', { name: user.name ?? user.username }) .getAttribute('src') - expect(beforeSrc).not.toEqual(afterSrc) + invariant(afterSrc, 'Updated profile photo src not found') + expect(afterSrc).not.toBe(beforeSrc) }) test('Users can change their email address', async ({ page, login }) => { @@ -104,13 +106,21 @@ test('Users can change their email address', async ({ page, login }) => { invariant(code, 'Onboarding code not found') await page.getByRole('textbox', { name: /code/i }).fill(code) await page.getByRole('button', { name: /submit/i }).click() - await expect(page.getByText(/email changed/i)).toBeVisible() - const updatedUser = await prisma.user.findUnique({ - where: { id: preUpdateUser.id }, - select: { email: true }, - }) - invariant(updatedUser, 'Updated user not found') + const updatedUser = await waitFor( + async () => { + const user = await prisma.user.findUnique({ + where: { id: preUpdateUser.id }, + select: { email: true }, + }) + if (!user) return null + if (user.email !== newEmailAddress) { + throw new Error('User email has not updated yet') + } + return user + }, + { timeout: 10_000, errorMessage: 'Updated user not found' }, + ) expect(updatedUser.email).toBe(newEmailAddress) const noticeEmail = await waitFor(() => readEmail(preUpdateUser.email), { errorMessage: 'Notice email was not sent', diff --git a/examples/federation/epic-stack/tests/mocks/github.ts b/examples/federation/epic-stack/tests/mocks/github.ts index 6290a8f..552a345 100644 --- a/examples/federation/epic-stack/tests/mocks/github.ts +++ b/examples/federation/epic-stack/tests/mocks/github.ts @@ -14,7 +14,7 @@ const githubUserFixturePath = path.join( '..', 'fixtures', 'github', - `users.${process.env.VITEST_POOL_ID || 0}.local.json`, + `users.${process.env.RSTEST_WORKER_ID || 0}.local.json`, ), ) @@ -76,6 +76,7 @@ async function getGitHubUsers() { return [] } catch (error) { console.error(error) + await fsExtra.remove(githubUserFixturePath) return [] } } @@ -93,7 +94,9 @@ export async function deleteGitHubUsers() { } async function setGitHubUsers(users: Array) { - await fsExtra.writeJson(githubUserFixturePath, users, { spaces: 2 }) + const tmpPath = `${githubUserFixturePath}.${process.pid}.${Date.now()}` + await fsExtra.writeJson(tmpPath, users, { spaces: 2 }) + await fsExtra.move(tmpPath, githubUserFixturePath, { overwrite: true }) } export async function insertGitHubUser(code?: string | null) { @@ -128,6 +131,7 @@ async function getUser(request: Request) { } const passthroughGitHub = + process.env.GITHUB_CLIENT_ID && !process.env.GITHUB_CLIENT_ID.startsWith('MOCK_') && process.env.NODE_ENV !== 'test' @@ -145,13 +149,10 @@ export const handlers: Array = [ user = await insertGitHubUser(code) } - return new Response( - new URLSearchParams({ - access_token: user.accessToken, - token_type: '__MOCK_TOKEN_TYPE__', - }).toString(), - { headers: { 'content-type': 'application/x-www-form-urlencoded' } }, - ) + return json({ + access_token: user.accessToken, + token_type: '__MOCK_TOKEN_TYPE__', + }) }, ), http.get('https://api.github.com/user/emails', async ({ request }) => { diff --git a/examples/federation/epic-stack/tests/mocks/index.ts b/examples/federation/epic-stack/tests/mocks/index.ts index e72b699..d1d6b10 100644 --- a/examples/federation/epic-stack/tests/mocks/index.ts +++ b/examples/federation/epic-stack/tests/mocks/index.ts @@ -1,8 +1,8 @@ import closeWithGrace from 'close-with-grace' import { setupServer } from 'msw/node' +import { handlers as federationHandlers } from './federation.ts' import { handlers as githubHandlers } from './github.ts' import { handlers as resendHandlers } from './resend.ts' -import { handlers as federationHandlers } from './federation.ts' export const server = setupServer(...resendHandlers, ...githubHandlers, ...federationHandlers) diff --git a/examples/federation/epic-stack/tests/mocks/remote/components/error-boundary.tsx b/examples/federation/epic-stack/tests/mocks/remote/components/error-boundary.tsx new file mode 100644 index 0000000..9f05aef --- /dev/null +++ b/examples/federation/epic-stack/tests/mocks/remote/components/error-boundary.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +type StatusHandler = (args: { params?: Record }) => ReactNode + +export function GeneralErrorBoundary({ + children, + statusHandlers, +}: { + children?: ReactNode + statusHandlers?: Record +}) { + if (statusHandlers?.[404]) { + return statusHandlers[404]({ params: {} }) + } + return children ?? null +} diff --git a/examples/federation/epic-stack/tests/mocks/remote/components/floating-toolbar.tsx b/examples/federation/epic-stack/tests/mocks/remote/components/floating-toolbar.tsx new file mode 100644 index 0000000..608d8e1 --- /dev/null +++ b/examples/federation/epic-stack/tests/mocks/remote/components/floating-toolbar.tsx @@ -0,0 +1,2 @@ +export const floatingToolbarClassName = + 'floating-toolbar' diff --git a/examples/federation/epic-stack/tests/mocks/remote/components/forms.tsx b/examples/federation/epic-stack/tests/mocks/remote/components/forms.tsx new file mode 100644 index 0000000..a66cab7 --- /dev/null +++ b/examples/federation/epic-stack/tests/mocks/remote/components/forms.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' + +export type ListOfErrors = Array | null | undefined + +export function ErrorList({ + id, + errors, +}: { + id?: string + errors?: ListOfErrors +}) { + const errorsToRender = errors?.filter(Boolean) + if (!errorsToRender?.length) return null + return ( +
    + {errorsToRender.map((error) => ( +
  • {error}
  • + ))} +
+ ) +} + +export function Field({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: React.InputHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const id = inputProps.id ?? inputProps.name + 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 id = textareaProps.id ?? textareaProps.name + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+