From e5ee4d100db798fce6b252239e33333e5df6a353 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:14:02 +0000 Subject: [PATCH 01/17] Migrate Vitest tests to browser mode Co-authored-by: me --- app/routes/_app+/users+/$username.test.tsx | 152 +++++---- app/utils/misc.use-double-check.test.tsx | 84 ++--- package-lock.json | 367 ++++++++------------- package.json | 6 +- tests/setup/custom-matchers.ts | 2 - tests/setup/setup-browser-env.ts | 6 + tests/setup/setup-test-env.ts | 3 - vitest.config.ts | 38 ++- 8 files changed, 301 insertions(+), 357 deletions(-) create mode 100644 tests/setup/setup-browser-env.ts diff --git a/app/routes/_app+/users+/$username.test.tsx b/app/routes/_app+/users+/$username.test.tsx index 3c432947..cd835d2a 100644 --- a/app/routes/_app+/users+/$username.test.tsx +++ b/app/routes/_app+/users+/$username.test.tsx @@ -1,91 +1,103 @@ -/** - * @vitest-environment jsdom - */ +import { page } from '@vitest/browser/context' import { createRoutesStub } from 'react-router' -import { render, screen } from '@testing-library/react' -import setCookieParser from 'set-cookie-parser' -import { test } from 'vitest' -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' -import { authSessionStorage } from '#app/utils/session.server.ts' -import { createUser } from '#tests/db-utils.ts' -import { default as UsernameRoute, loader } from './$username.tsx' +import { type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, expect, test, vi } from 'vitest' +import { useOptionalUser } from '#app/utils/user.ts' +import { default as UsernameRoute } from './$username.tsx' -type RootLoaderArgs = Parameters[0] -type UsernameLoaderArgs = Parameters[0] +vi.mock('#app/utils/user.ts', () => ({ + useOptionalUser: vi.fn(), +})) -test('The user profile when not logged in as self', async () => { - const user = await prisma.user.create({ - select: { id: true, username: true, name: true }, - data: { ...createUser() }, - }) - const App = createRoutesStub([ +const mockedUseOptionalUser = vi.mocked(useOptionalUser) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +const render = (ui: ReactElement) => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + root.render(ui) +} + +afterEach(() => { + root?.unmount() + root = null + container?.remove() + container = null +}) + +type UserStub = { + id: string + username: string + name: string + createdAt: Date +} + +const buildApp = (user: UserStub) => { + return createRoutesStub([ { id: 'routes/users.$username', path: '/users/:username', Component: UsernameRoute, - loader, + loader: async () => ({ + user, + userJoinedDisplay: user.createdAt.toLocaleDateString(), + }), }, ]) +} + +test('The user profile when not logged in as self', async () => { + const user = { + id: 'user_1', + username: 'harry', + name: 'Harry Example', + createdAt: new Date('2024-01-01T00:00:00Z'), + } + mockedUseOptionalUser.mockReturnValue(null) + const App = buildApp(user) const routeUrl = `/users/${user.username}` render() - await screen.findByRole('heading', { level: 1, name: user.name! }) - await screen.findByRole('link', { name: `${user.name}'s recipients` }) + await expect + .element(page.getByRole('heading', { level: 1, name: user.name })) + .toBeVisible() + await expect + .element(page.getByRole('link', { name: `${user.name}'s recipients` })) + .toBeVisible() }) test('The user profile when logged in as self', async () => { - const user = await prisma.user.create({ - select: { id: true, username: true, name: true }, - data: { ...createUser() }, - }) - const session = await prisma.session.create({ - select: { id: true }, - data: { - expirationDate: getSessionExpirationDate({ isRenewal: false }), - userId: user.id, - }, + const user = { + id: 'user_2', + username: 'logan', + name: 'Logan Example', + createdAt: new Date('2024-01-01T00:00:00Z'), + } + mockedUseOptionalUser.mockReturnValue({ + id: user.id, + username: user.username, + name: user.name, }) - - const authSession = await authSessionStorage.getSession() - authSession.set(sessionKey, session.id) - const setCookieHeader = await authSessionStorage.commitSession(authSession) - const parsedCookie = setCookieParser.parseString(setCookieHeader) - const cookieHeader = new URLSearchParams({ - [parsedCookie.name]: parsedCookie.value, - }).toString() - - const App = createRoutesStub([ - { - id: 'root', - path: '/', - loader: async (args: RootLoaderArgs) => { - // add the cookie header to the request - args.request.headers.set('cookie', cookieHeader) - return rootLoader(args) - }, - children: [ - { - id: 'routes/users.$username', - path: 'users/:username', - Component: UsernameRoute, - loader: async (args: UsernameLoaderArgs) => { - // add the cookie header to the request - args.request.headers.set('cookie', cookieHeader) - return loader(args) - }, - }, - ], - }, - ]) + const App = buildApp(user) const routeUrl = `/users/${user.username}` - await render() + render() - await screen.findByRole('heading', { level: 1, name: user.name! }) - await screen.findByRole('button', { name: /logout/i }) - await screen.findByRole('link', { name: /my recipients/i }) - await screen.findByRole('link', { name: /edit profile/i }) + await expect + .element(page.getByRole('heading', { level: 1, name: user.name })) + .toBeVisible() + await expect + .element(page.getByRole('button', { name: /logout/i })) + .toBeVisible() + await expect + .element(page.getByRole('link', { name: /my recipients/i })) + .toBeVisible() + await expect + .element(page.getByRole('link', { name: /edit profile/i })) + .toBeVisible() }) diff --git a/app/utils/misc.use-double-check.test.tsx b/app/utils/misc.use-double-check.test.tsx index 6bc20f3b..950ee1ed 100644 --- a/app/utils/misc.use-double-check.test.tsx +++ b/app/utils/misc.use-double-check.test.tsx @@ -1,12 +1,26 @@ -/** - * @vitest-environment jsdom - */ -import { render, screen, waitFor } from '@testing-library/react' -import { userEvent } from '@testing-library/user-event' -import { useState } from 'react' -import { expect, test } from 'vitest' +import { page, userEvent } from '@vitest/browser/context' +import { useState, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, expect, test } from 'vitest' import { useDoubleCheck } from './misc.tsx' +let root: Root | null = null +let container: HTMLDivElement | null = null + +const render = (ui: ReactElement) => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + root.render(ui) +} + +afterEach(() => { + root?.unmount() + root = null + container?.remove() + container = null +}) + function TestComponent({ safeDelayMs = 0 }: { safeDelayMs?: number }) { const [defaultPrevented, setDefaultPrevented] = useState< 'idle' | 'no' | 'yes' @@ -31,68 +45,62 @@ test('prevents default on the first click, and does not on the second', async () const user = userEvent.setup() render() - const status = screen.getByRole('status') - const button = screen.getByRole('button') + const status = page.getByRole('status') + const button = page.getByRole('button') - expect(status).toHaveTextContent('Default Prevented: idle') - expect(button).toHaveTextContent('Click me') + await expect.element(status).toHaveTextContent('Default Prevented: idle') + await expect.element(button).toHaveTextContent('Click me') await user.click(button) - expect(button).toHaveTextContent('You sure?') - expect(status).toHaveTextContent('Default Prevented: yes') - await waitFor(() => - expect(button).toHaveAttribute('data-safe-delay', 'true'), - ) + await expect.element(button).toHaveTextContent('You sure?') + await expect.element(status).toHaveTextContent('Default Prevented: yes') + await expect.element(button).toHaveAttribute('data-safe-delay', 'true') // clicking it during the safe delay does nothing await user.click(button) - expect(button).toHaveTextContent('You sure?') - expect(status).toHaveTextContent('Default Prevented: yes') - await waitFor(() => - expect(button).toHaveAttribute('data-safe-delay', 'true'), - ) + await expect.element(button).toHaveTextContent('You sure?') + await expect.element(status).toHaveTextContent('Default Prevented: yes') + await expect.element(button).toHaveAttribute('data-safe-delay', 'true') - await waitFor(() => - expect(button).toHaveAttribute('data-safe-delay', 'false'), - ) + await expect.element(button).toHaveAttribute('data-safe-delay', 'false') await user.click(button) - expect(button).toHaveTextContent('You sure?') - expect(status).toHaveTextContent('Default Prevented: no') + await expect.element(button).toHaveTextContent('You sure?') + await expect.element(status).toHaveTextContent('Default Prevented: no') }) test('blurring the button starts things over', async () => { const user = userEvent.setup() render() - const status = screen.getByRole('status') - const button = screen.getByRole('button') + const status = page.getByRole('status') + const button = page.getByRole('button') await user.click(button) - expect(button).toHaveTextContent('You sure?') - expect(status).toHaveTextContent('Default Prevented: yes') + await expect.element(button).toHaveTextContent('You sure?') + await expect.element(status).toHaveTextContent('Default Prevented: yes') await user.click(document.body) // button goes back to click me - expect(button).toHaveTextContent('Click me') + await expect.element(button).toHaveTextContent('Click me') // our callback wasn't called, so the status doesn't change - expect(status).toHaveTextContent('Default Prevented: yes') + await expect.element(status).toHaveTextContent('Default Prevented: yes') }) test('hitting "escape" on the input starts things over', async () => { const user = userEvent.setup() render() - const status = screen.getByRole('status') - const button = screen.getByRole('button') + const status = page.getByRole('status') + const button = page.getByRole('button') await user.click(button) - expect(button).toHaveTextContent('You sure?') - expect(status).toHaveTextContent('Default Prevented: yes') + await expect.element(button).toHaveTextContent('You sure?') + await expect.element(status).toHaveTextContent('Default Prevented: yes') await user.keyboard('{Escape}') // button goes back to click me - expect(button).toHaveTextContent('Click me') + await expect.element(button).toHaveTextContent('Click me') // our callback wasn't called, so the status doesn't change - expect(status).toHaveTextContent('Default Prevented: yes') + await expect.element(status).toHaveTextContent('Default Prevented: yes') }) diff --git a/package-lock.json b/package-lock.json index ed4b0a4e..2cd1c864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,10 +84,6 @@ "@react-router/serve": "^7.13.0", "@sentry/vite-plugin": "^2.21.1", "@sly-cli/sly": "^2.1.1", - "@testing-library/dom": "^10.3.2", - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.5.2", "@total-typescript/ts-reset": "^0.6.1", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", @@ -105,6 +101,8 @@ "@types/set-cookie-parser": "^2.4.10", "@types/source-map-support": "^0.5.10", "@vitejs/plugin-react": "^5.1.2", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "enforce-unique": "^1.3.0", "esbuild": "^0.27.2", @@ -129,13 +127,6 @@ "node": "^24.0.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", @@ -2757,6 +2748,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@prisma/adapter-better-sqlite3": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.3.0.tgz", @@ -6005,95 +6003,6 @@ "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, "node_modules/@total-typescript/ts-reset": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", @@ -6111,13 +6020,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7036,6 +6938,73 @@ "node": ">=0.10.0" } }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -7403,16 +7372,6 @@ "node": ">=10" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -8597,13 +8556,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -8881,16 +8833,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -8961,13 +8903,6 @@ "node": ">=0.10.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -11252,16 +11187,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -12561,16 +12486,6 @@ "node": ">=12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -12761,16 +12676,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12866,6 +12771,16 @@ "node": ">= 0.8" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13988,6 +13903,29 @@ "node": ">=4" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -14290,34 +14228,6 @@ } } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -14768,13 +14678,6 @@ "react": "^19.2.4" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -14937,20 +14840,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15843,6 +15732,21 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -16275,19 +16179,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -16548,6 +16439,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", diff --git a/package.json b/package.json index 4e7c5d03..493c0392 100644 --- a/package.json +++ b/package.json @@ -115,10 +115,6 @@ "@react-router/serve": "^7.13.0", "@sentry/vite-plugin": "^2.21.1", "@sly-cli/sly": "^2.1.1", - "@testing-library/dom": "^10.3.2", - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.5.2", "@total-typescript/ts-reset": "^0.6.1", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", @@ -136,6 +132,8 @@ "@types/set-cookie-parser": "^2.4.10", "@types/source-map-support": "^0.5.10", "@vitejs/plugin-react": "^5.1.2", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "enforce-unique": "^1.3.0", "esbuild": "^0.27.2", diff --git a/tests/setup/custom-matchers.ts b/tests/setup/custom-matchers.ts index 25865208..d9496702 100644 --- a/tests/setup/custom-matchers.ts +++ b/tests/setup/custom-matchers.ts @@ -10,8 +10,6 @@ import { } from '#app/utils/toast.server.ts' import { convertSetCookieToCookie } from '#tests/utils.ts' -import '@testing-library/jest-dom/vitest' - expect.extend({ toHaveRedirect(response: Response, redirectTo?: string) { const location = response.headers.get('location') diff --git a/tests/setup/setup-browser-env.ts b/tests/setup/setup-browser-env.ts new file mode 100644 index 00000000..d85c8cb0 --- /dev/null +++ b/tests/setup/setup-browser-env.ts @@ -0,0 +1,6 @@ +import '@vitest/browser/matchers' +import { afterEach } from 'vitest' + +afterEach(() => { + document.body.innerHTML = '' +}) diff --git a/tests/setup/setup-test-env.ts b/tests/setup/setup-test-env.ts index 18e20661..68f8bff2 100644 --- a/tests/setup/setup-test-env.ts +++ b/tests/setup/setup-test-env.ts @@ -3,14 +3,11 @@ import './db-setup.ts' 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 { server } from '#tests/mocks/index.ts' import './custom-matchers.ts' afterEach(() => server.resetHandlers()) -afterEach(() => cleanup()) - export let consoleError: MockInstance<(typeof console)['error']> beforeEach(() => { diff --git a/vitest.config.ts b/vitest.config.ts index 21a48cf9..8ba9525e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,42 @@ /// import react from '@vitejs/plugin-react' +import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], css: { postcss: { plugins: [] } }, test: { - include: ['./app/**/*.test.{ts,tsx}'], - setupFiles: ['./tests/setup/setup-test-env.ts'], - globalSetup: ['./tests/setup/global-setup.ts'], - restoreMocks: true, - coverage: { - include: ['app/**/*.{ts,tsx}'], - }, + projects: [ + { + name: 'node', + extends: true, + test: { + include: ['./app/**/*.test.ts'], + setupFiles: ['./tests/setup/setup-test-env.ts'], + globalSetup: ['./tests/setup/global-setup.ts'], + restoreMocks: true, + coverage: { + include: ['app/**/*.{ts,tsx}'], + }, + }, + }, + { + name: 'browser', + extends: true, + test: { + include: ['./app/**/*.test.tsx'], + setupFiles: ['./tests/setup/setup-browser-env.ts'], + restoreMocks: true, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + }, + }, + ], }, }) From 5912963c607900b77043b0ba5b8e8a9577f9523c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:22:44 +0000 Subject: [PATCH 02/17] Use vitest browser helpers Co-authored-by: me --- app/routes/_app+/users+/$username.test.tsx | 2 +- app/utils/misc.use-double-check.test.tsx | 2 +- vitest.config.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/routes/_app+/users+/$username.test.tsx b/app/routes/_app+/users+/$username.test.tsx index cd835d2a..3b21a3e7 100644 --- a/app/routes/_app+/users+/$username.test.tsx +++ b/app/routes/_app+/users+/$username.test.tsx @@ -1,4 +1,4 @@ -import { page } from '@vitest/browser/context' +import { page } from 'vitest/browser' import { createRoutesStub } from 'react-router' import { type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' diff --git a/app/utils/misc.use-double-check.test.tsx b/app/utils/misc.use-double-check.test.tsx index 950ee1ed..d619d9bb 100644 --- a/app/utils/misc.use-double-check.test.tsx +++ b/app/utils/misc.use-double-check.test.tsx @@ -1,4 +1,4 @@ -import { page, userEvent } from '@vitest/browser/context' +import { page, userEvent } from 'vitest/browser' import { useState, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test } from 'vitest' diff --git a/vitest.config.ts b/vitest.config.ts index 8ba9525e..e7d9beb4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ provider: playwright(), instances: [{ browser: 'chromium' }], headless: true, + ui: false, }, }, }, From db8c0f33d08dedc7e5bded814c77ca2a3617f427 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:38:57 +0000 Subject: [PATCH 03/17] Stub profile route loader data Co-authored-by: me --- app/routes/_app+/users+/$username.test.tsx | 79 +++++++++++++--------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/app/routes/_app+/users+/$username.test.tsx b/app/routes/_app+/users+/$username.test.tsx index 3b21a3e7..701645b8 100644 --- a/app/routes/_app+/users+/$username.test.tsx +++ b/app/routes/_app+/users+/$username.test.tsx @@ -1,16 +1,52 @@ import { page } from 'vitest/browser' -import { createRoutesStub } from 'react-router' -import { type ReactElement } from 'react' +import { + type ComponentPropsWithoutRef, + type ReactElement, + type ReactNode, +} from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test, vi } from 'vitest' import { useOptionalUser } from '#app/utils/user.ts' import { default as UsernameRoute } from './$username.tsx' +type LinkProps = ComponentPropsWithoutRef<'a'> & { + to?: string | { pathname?: string } + children?: ReactNode +} + +type FormProps = ComponentPropsWithoutRef<'form'> & { + children?: ReactNode +} + +const routerMocks = vi.hoisted(() => ({ + useLoaderData: vi.fn(), + Link: ({ to, children, ...props }: LinkProps) => ( + + {children} + + ), + Form: ({ children, ...props }: FormProps) => ( +
{children}
+ ), +})) + +vi.mock('react-router', async () => { + const actual = + await vi.importActual('react-router') + return { + ...actual, + useLoaderData: routerMocks.useLoaderData, + Link: routerMocks.Link, + Form: routerMocks.Form, + } +}) + vi.mock('#app/utils/user.ts', () => ({ useOptionalUser: vi.fn(), })) const mockedUseOptionalUser = vi.mocked(useOptionalUser) +const mockedUseLoaderData = routerMocks.useLoaderData let root: Root | null = null let container: HTMLDivElement | null = null @@ -29,27 +65,6 @@ afterEach(() => { container = null }) -type UserStub = { - id: string - username: string - name: string - createdAt: Date -} - -const buildApp = (user: UserStub) => { - return createRoutesStub([ - { - id: 'routes/users.$username', - path: '/users/:username', - Component: UsernameRoute, - loader: async () => ({ - user, - userJoinedDisplay: user.createdAt.toLocaleDateString(), - }), - }, - ]) -} - test('The user profile when not logged in as self', async () => { const user = { id: 'user_1', @@ -58,10 +73,11 @@ test('The user profile when not logged in as self', async () => { createdAt: new Date('2024-01-01T00:00:00Z'), } mockedUseOptionalUser.mockReturnValue(null) - const App = buildApp(user) - - const routeUrl = `/users/${user.username}` - render() + mockedUseLoaderData.mockReturnValue({ + user, + userJoinedDisplay: user.createdAt.toLocaleDateString(), + }) + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) @@ -83,10 +99,11 @@ test('The user profile when logged in as self', async () => { username: user.username, name: user.name, }) - const App = buildApp(user) - - const routeUrl = `/users/${user.username}` - render() + mockedUseLoaderData.mockReturnValue({ + user, + userJoinedDisplay: user.createdAt.toLocaleDateString(), + }) + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) From 1fdfe7a6cec6ef10f3c8e4dc69f117a6c04fd9da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:48:25 +0000 Subject: [PATCH 04/17] Extract user profile component Co-authored-by: me --- app/routes/_app+/users+/$username.test.tsx | 6 +- app/routes/_app+/users+/$username.tsx | 68 ++++------------------ app/routes/_app+/users+/user-profile.tsx | 67 +++++++++++++++++++++ 3 files changed, 80 insertions(+), 61 deletions(-) create mode 100644 app/routes/_app+/users+/user-profile.tsx diff --git a/app/routes/_app+/users+/$username.test.tsx b/app/routes/_app+/users+/$username.test.tsx index 701645b8..53b27f7a 100644 --- a/app/routes/_app+/users+/$username.test.tsx +++ b/app/routes/_app+/users+/$username.test.tsx @@ -7,7 +7,7 @@ import { import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test, vi } from 'vitest' import { useOptionalUser } from '#app/utils/user.ts' -import { default as UsernameRoute } from './$username.tsx' +import { default as UserProfile } from './user-profile.tsx' type LinkProps = ComponentPropsWithoutRef<'a'> & { to?: string | { pathname?: string } @@ -77,7 +77,7 @@ test('The user profile when not logged in as self', async () => { user, userJoinedDisplay: user.createdAt.toLocaleDateString(), }) - render() + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) @@ -103,7 +103,7 @@ test('The user profile when logged in as self', async () => { user, userJoinedDisplay: user.createdAt.toLocaleDateString(), }) - render() + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) diff --git a/app/routes/_app+/users+/$username.tsx b/app/routes/_app+/users+/$username.tsx index 34836f8c..e564d10d 100644 --- a/app/routes/_app+/users+/$username.tsx +++ b/app/routes/_app+/users+/$username.tsx @@ -2,17 +2,11 @@ import { invariantResponse } from '@epic-web/invariant' import { data as json, type LoaderFunctionArgs, - Form, - Link, - useLoaderData, type MetaFunction, } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' -import { Spacer } from '#app/components/spacer.tsx' -import { Button } from '#app/components/ui/button.tsx' -import { Icon } from '#app/components/ui/icon.tsx' import { prisma } from '#app/utils/db.server.ts' -import { useOptionalUser } from '#app/utils/user.ts' +import UserProfile, { type UserProfileLoaderData } from './user-profile.tsx' export async function loader({ params }: LoaderFunctionArgs) { const user = await prisma.user.findFirst({ @@ -29,60 +23,18 @@ export async function loader({ params }: LoaderFunctionArgs) { invariantResponse(user, 'User not found', { status: 404 }) - return json({ user, userJoinedDisplay: user.createdAt.toLocaleDateString() }) + return json({ + user: { + id: user.id, + name: user.name, + username: user.username, + }, + userJoinedDisplay: user.createdAt.toLocaleDateString(), + }) } export default function ProfileRoute() { - const data = useLoaderData() - const user = data.user - const userDisplayName = user.name ?? user.username - const loggedInUser = useOptionalUser() - const isLoggedInUser = data.user.id === loggedInUser?.id - - return ( -
- -
-
-

{userDisplayName}

-
-

- Joined {data.userJoinedDisplay} -

- {isLoggedInUser ? ( -
- -
- ) : null} -
- {isLoggedInUser ? ( - <> - - - - ) : ( - - )} -
-
-
- ) + return } export const meta: MetaFunction = ({ data, params }) => { diff --git a/app/routes/_app+/users+/user-profile.tsx b/app/routes/_app+/users+/user-profile.tsx new file mode 100644 index 00000000..d32adf4c --- /dev/null +++ b/app/routes/_app+/users+/user-profile.tsx @@ -0,0 +1,67 @@ +import { Form, Link, useLoaderData } from 'react-router' +import { Spacer } from '#app/components/spacer.tsx' +import { Button } from '#app/components/ui/button.tsx' +import { Icon } from '#app/components/ui/icon.tsx' +import { useOptionalUser } from '#app/utils/user.ts' + +export type UserProfileLoaderData = { + user: { + id: string + name: string | null + username: string + } + userJoinedDisplay: string +} + +export default function UserProfile() { + const data = useLoaderData() + const user = data.user + const userDisplayName = user.name ?? user.username + const loggedInUser = useOptionalUser() + const isLoggedInUser = data.user.id === loggedInUser?.id + + return ( +
+ +
+
+

{userDisplayName}

+
+

+ Joined {data.userJoinedDisplay} +

+ {isLoggedInUser ? ( +
+ +
+ ) : null} +
+ {isLoggedInUser ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} From aab855c8359b526a8b04bbdf6acce3aee8a973f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 00:55:17 +0000 Subject: [PATCH 05/17] Move user profile browser test Co-authored-by: me --- .../_app+/users+/{$username.test.tsx => user-profile.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/routes/_app+/users+/{$username.test.tsx => user-profile.test.tsx} (100%) diff --git a/app/routes/_app+/users+/$username.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx similarity index 100% rename from app/routes/_app+/users+/$username.test.tsx rename to app/routes/_app+/users+/user-profile.test.tsx From fb71613d21709eaaab125a90d705f9404b7b4fc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:03:19 +0000 Subject: [PATCH 06/17] Simplify user profile test mocks Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index 53b27f7a..446f8f7e 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -6,6 +6,7 @@ import { } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test, vi } from 'vitest' +import { useLoaderData } from 'react-router' import { useOptionalUser } from '#app/utils/user.ts' import { default as UserProfile } from './user-profile.tsx' @@ -18,7 +19,7 @@ type FormProps = ComponentPropsWithoutRef<'form'> & { children?: ReactNode } -const routerMocks = vi.hoisted(() => ({ +vi.mock('react-router', () => ({ useLoaderData: vi.fn(), Link: ({ to, children, ...props }: LinkProps) => ( @@ -30,23 +31,12 @@ const routerMocks = vi.hoisted(() => ({ ), })) -vi.mock('react-router', async () => { - const actual = - await vi.importActual('react-router') - return { - ...actual, - useLoaderData: routerMocks.useLoaderData, - Link: routerMocks.Link, - Form: routerMocks.Form, - } -}) - vi.mock('#app/utils/user.ts', () => ({ useOptionalUser: vi.fn(), })) const mockedUseOptionalUser = vi.mocked(useOptionalUser) -const mockedUseLoaderData = routerMocks.useLoaderData +const mockedUseLoaderData = vi.mocked(useLoaderData) let root: Root | null = null let container: HTMLDivElement | null = null From 7523c44ecaa68c76eb9d1ef93c722c8076541301 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:10:10 +0000 Subject: [PATCH 07/17] Use routes stub in user profile test Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index 446f8f7e..49b172b4 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -1,42 +1,16 @@ import { page } from 'vitest/browser' -import { - type ComponentPropsWithoutRef, - type ReactElement, - type ReactNode, -} from 'react' +import { createRoutesStub } from 'react-router' +import { type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test, vi } from 'vitest' -import { useLoaderData } from 'react-router' import { useOptionalUser } from '#app/utils/user.ts' import { default as UserProfile } from './user-profile.tsx' -type LinkProps = ComponentPropsWithoutRef<'a'> & { - to?: string | { pathname?: string } - children?: ReactNode -} - -type FormProps = ComponentPropsWithoutRef<'form'> & { - children?: ReactNode -} - -vi.mock('react-router', () => ({ - useLoaderData: vi.fn(), - Link: ({ to, children, ...props }: LinkProps) => ( - - {children} - - ), - Form: ({ children, ...props }: FormProps) => ( -
{children}
- ), -})) - vi.mock('#app/utils/user.ts', () => ({ useOptionalUser: vi.fn(), })) const mockedUseOptionalUser = vi.mocked(useOptionalUser) -const mockedUseLoaderData = vi.mocked(useLoaderData) let root: Root | null = null let container: HTMLDivElement | null = null @@ -55,6 +29,27 @@ afterEach(() => { container = null }) +type UserStub = { + id: string + username: string + name: string + createdAt: Date +} + +const buildApp = (user: UserStub) => { + return createRoutesStub([ + { + id: 'routes/users.$username', + path: '/users/:username', + Component: UserProfile, + loader: async () => ({ + user, + userJoinedDisplay: user.createdAt.toLocaleDateString(), + }), + }, + ]) +} + test('The user profile when not logged in as self', async () => { const user = { id: 'user_1', @@ -63,11 +58,10 @@ test('The user profile when not logged in as self', async () => { createdAt: new Date('2024-01-01T00:00:00Z'), } mockedUseOptionalUser.mockReturnValue(null) - mockedUseLoaderData.mockReturnValue({ - user, - userJoinedDisplay: user.createdAt.toLocaleDateString(), - }) - render() + const App = buildApp(user) + + const routeUrl = `/users/${user.username}` + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) @@ -89,11 +83,10 @@ test('The user profile when logged in as self', async () => { username: user.username, name: user.name, }) - mockedUseLoaderData.mockReturnValue({ - user, - userJoinedDisplay: user.createdAt.toLocaleDateString(), - }) - render() + const App = buildApp(user) + + const routeUrl = `/users/${user.username}` + render() await expect .element(page.getByRole('heading', { level: 1, name: user.name })) From 347732310efe827a20e791e174a2901e1b313c3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:17:52 +0000 Subject: [PATCH 08/17] Extract user profile view Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 63 +++++-------------- app/routes/_app+/users+/user-profile.tsx | 44 +++++++++---- 2 files changed, 49 insertions(+), 58 deletions(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index 49b172b4..319ac83b 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -1,16 +1,8 @@ import { page } from 'vitest/browser' -import { createRoutesStub } from 'react-router' import { type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, expect, test, vi } from 'vitest' -import { useOptionalUser } from '#app/utils/user.ts' -import { default as UserProfile } from './user-profile.tsx' - -vi.mock('#app/utils/user.ts', () => ({ - useOptionalUser: vi.fn(), -})) - -const mockedUseOptionalUser = vi.mocked(useOptionalUser) +import { afterEach, expect, test } from 'vitest' +import { UserProfileView } from './user-profile.tsx' let root: Root | null = null let container: HTMLDivElement | null = null @@ -29,39 +21,19 @@ afterEach(() => { container = null }) -type UserStub = { - id: string - username: string - name: string - createdAt: Date -} - -const buildApp = (user: UserStub) => { - return createRoutesStub([ - { - id: 'routes/users.$username', - path: '/users/:username', - Component: UserProfile, - loader: async () => ({ - user, - userJoinedDisplay: user.createdAt.toLocaleDateString(), - }), - }, - ]) -} - test('The user profile when not logged in as self', async () => { const user = { id: 'user_1', username: 'harry', name: 'Harry Example', - createdAt: new Date('2024-01-01T00:00:00Z'), } - mockedUseOptionalUser.mockReturnValue(null) - const App = buildApp(user) - - const routeUrl = `/users/${user.username}` - render() + render( + , + ) await expect .element(page.getByRole('heading', { level: 1, name: user.name })) @@ -76,17 +48,14 @@ test('The user profile when logged in as self', async () => { id: 'user_2', username: 'logan', name: 'Logan Example', - createdAt: new Date('2024-01-01T00:00:00Z'), } - mockedUseOptionalUser.mockReturnValue({ - id: user.id, - username: user.username, - name: user.name, - }) - const App = buildApp(user) - - const routeUrl = `/users/${user.username}` - render() + render( + , + ) await expect .element(page.getByRole('heading', { level: 1, name: user.name })) diff --git a/app/routes/_app+/users+/user-profile.tsx b/app/routes/_app+/users+/user-profile.tsx index d32adf4c..90dac58d 100644 --- a/app/routes/_app+/users+/user-profile.tsx +++ b/app/routes/_app+/users+/user-profile.tsx @@ -4,21 +4,29 @@ import { Button } from '#app/components/ui/button.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { useOptionalUser } from '#app/utils/user.ts' +export type UserProfileUser = { + id: string + name: string | null + username: string +} + export type UserProfileLoaderData = { - user: { - id: string - name: string | null - username: string - } + user: UserProfileUser userJoinedDisplay: string } -export default function UserProfile() { - const data = useLoaderData() - const user = data.user +export type UserProfileViewProps = { + user: UserProfileUser + userJoinedDisplay: string + isLoggedInUser: boolean +} + +export function UserProfileView({ + user, + userJoinedDisplay, + isLoggedInUser, +}: UserProfileViewProps) { const userDisplayName = user.name ?? user.username - const loggedInUser = useOptionalUser() - const isLoggedInUser = data.user.id === loggedInUser?.id return (
@@ -28,7 +36,7 @@ export default function UserProfile() {

{userDisplayName}

- Joined {data.userJoinedDisplay} + Joined {userJoinedDisplay}

{isLoggedInUser ? (
@@ -65,3 +73,17 @@ export default function UserProfile() { ) } + +export default function UserProfile() { + const data = useLoaderData() + const loggedInUser = useOptionalUser() + const isLoggedInUser = data.user.id === loggedInUser?.id + + return ( + + ) +} From 991fef051773290fedca744daa81c874575b6042 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:24:55 +0000 Subject: [PATCH 09/17] Mock react-router in user profile test Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index 319ac83b..e8dd39bd 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -1,9 +1,33 @@ import { page } from 'vitest/browser' -import { type ReactElement } from 'react' +import { + type ComponentPropsWithoutRef, + type ReactElement, + type ReactNode, +} from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, expect, test } from 'vitest' +import { afterEach, expect, test, vi } from 'vitest' import { UserProfileView } from './user-profile.tsx' +type LinkProps = ComponentPropsWithoutRef<'a'> & { + to?: string | { pathname?: string } + children?: ReactNode +} + +type FormProps = ComponentPropsWithoutRef<'form'> & { + children?: ReactNode +} + +vi.mock('react-router', () => ({ + Link: ({ to, children, ...props }: LinkProps) => ( + + {children} + + ), + Form: ({ children, ...props }: FormProps) => ( + {children} + ), +})) + let root: Root | null = null let container: HTMLDivElement | null = null From a63592c6d6735e3be5ee0abf8f139831b6f6a6be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:32:20 +0000 Subject: [PATCH 10/17] Move user profile component Co-authored-by: me --- app/{routes/_app+/users+ => components}/user-profile.tsx | 0 app/routes/_app+/users+/$username.tsx | 4 +++- app/routes/_app+/users+/user-profile.test.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) rename app/{routes/_app+/users+ => components}/user-profile.tsx (100%) diff --git a/app/routes/_app+/users+/user-profile.tsx b/app/components/user-profile.tsx similarity index 100% rename from app/routes/_app+/users+/user-profile.tsx rename to app/components/user-profile.tsx diff --git a/app/routes/_app+/users+/$username.tsx b/app/routes/_app+/users+/$username.tsx index e564d10d..b869a909 100644 --- a/app/routes/_app+/users+/$username.tsx +++ b/app/routes/_app+/users+/$username.tsx @@ -6,7 +6,9 @@ import { } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { prisma } from '#app/utils/db.server.ts' -import UserProfile, { type UserProfileLoaderData } from './user-profile.tsx' +import UserProfile, { + type UserProfileLoaderData, +} from '#app/components/user-profile.tsx' export async function loader({ params }: LoaderFunctionArgs) { const user = await prisma.user.findFirst({ diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index e8dd39bd..dcdfb76d 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -6,7 +6,7 @@ import { } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test, vi } from 'vitest' -import { UserProfileView } from './user-profile.tsx' +import { UserProfileView } from '#app/components/user-profile.tsx' type LinkProps = ComponentPropsWithoutRef<'a'> & { to?: string | { pathname?: string } From 3bec8d41863fbe1cb14e14b349ebfe84d305a7bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:40:35 +0000 Subject: [PATCH 11/17] Use RouterProvider in profile test Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 69 ++++++++----------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index dcdfb76d..478a065f 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -1,33 +1,10 @@ import { page } from 'vitest/browser' -import { - type ComponentPropsWithoutRef, - type ReactElement, - type ReactNode, -} from 'react' +import { type ComponentProps, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, expect, test } from 'vitest' import { UserProfileView } from '#app/components/user-profile.tsx' -type LinkProps = ComponentPropsWithoutRef<'a'> & { - to?: string | { pathname?: string } - children?: ReactNode -} - -type FormProps = ComponentPropsWithoutRef<'form'> & { - children?: ReactNode -} - -vi.mock('react-router', () => ({ - Link: ({ to, children, ...props }: LinkProps) => ( - - {children} - - ), - Form: ({ children, ...props }: FormProps) => ( -
{children}
- ), -})) - let root: Root | null = null let container: HTMLDivElement | null = null @@ -38,6 +15,20 @@ const render = (ui: ReactElement) => { root.render(ui) } +const renderProfile = (props: ComponentProps) => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + ], + { initialEntries: ['/'] }, + ) + + render() +} + afterEach(() => { root?.unmount() root = null @@ -51,13 +42,12 @@ test('The user profile when not logged in as self', async () => { username: 'harry', name: 'Harry Example', } - render( - , - ) + + renderProfile({ + user, + userJoinedDisplay: 'Jan 1, 2024', + isLoggedInUser: false, + }) await expect .element(page.getByRole('heading', { level: 1, name: user.name })) @@ -73,13 +63,12 @@ test('The user profile when logged in as self', async () => { username: 'logan', name: 'Logan Example', } - render( - , - ) + + renderProfile({ + user, + userJoinedDisplay: 'Jan 1, 2024', + isLoggedInUser: true, + }) await expect .element(page.getByRole('heading', { level: 1, name: user.name })) From 18786cf8c3bf9bb7c585d7faa93838708f1a05d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:47:16 +0000 Subject: [PATCH 12/17] Use RouterProvider in profile test Co-authored-by: me --- app/routes/_app+/users+/user-profile.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/routes/_app+/users+/user-profile.test.tsx index 478a065f..9b4d6fb1 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/routes/_app+/users+/user-profile.test.tsx @@ -3,7 +3,7 @@ import { type ComponentProps, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { createMemoryRouter, RouterProvider } from 'react-router' import { afterEach, expect, test } from 'vitest' -import { UserProfileView } from '#app/components/user-profile.tsx' +import { UserProfileView } from '../../../components/user-profile.tsx' let root: Root | null = null let container: HTMLDivElement | null = null From 04fc6e7fc638023be2a44361c26b2a1f2096e53b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 01:53:57 +0000 Subject: [PATCH 13/17] Move user profile test to components Co-authored-by: me --- app/{routes/_app+/users+ => components}/user-profile.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename app/{routes/_app+/users+ => components}/user-profile.test.tsx (96%) diff --git a/app/routes/_app+/users+/user-profile.test.tsx b/app/components/user-profile.test.tsx similarity index 96% rename from app/routes/_app+/users+/user-profile.test.tsx rename to app/components/user-profile.test.tsx index 9b4d6fb1..a63241c1 100644 --- a/app/routes/_app+/users+/user-profile.test.tsx +++ b/app/components/user-profile.test.tsx @@ -3,7 +3,7 @@ import { type ComponentProps, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { createMemoryRouter, RouterProvider } from 'react-router' import { afterEach, expect, test } from 'vitest' -import { UserProfileView } from '../../../components/user-profile.tsx' +import { UserProfileView } from './user-profile.tsx' let root: Root | null = null let container: HTMLDivElement | null = null From 90266f7af1b20960b184b230f937a598d9392afd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 02:05:10 +0000 Subject: [PATCH 14/17] Fix lint import ordering Co-authored-by: me --- app/components/error-boundary.tsx | 2 +- app/components/progress-bar.tsx | 2 +- app/components/search-bar.tsx | 2 +- app/components/ui/button.tsx | 2 +- app/components/user-profile.test.tsx | 2 +- app/entry.server.tsx | 8 ++++---- app/routes/_app+/_layout.tsx | 8 ++++++-- app/routes/_app+/admin+/source.tsx | 7 ++++--- app/routes/_app+/recipients+/$recipientId.edit.tsx | 2 +- app/routes/_app+/recipients+/$recipientId.past.tsx | 6 ++++-- app/routes/_app+/recipients+/$recipientId.tsx | 5 +++-- app/routes/_app+/recipients+/__editor.tsx | 2 +- app/routes/_app+/recipients+/new.tsx | 4 ++-- app/routes/_app+/settings.profile+/change-number.tsx | 5 ++++- app/routes/_app+/settings.profile+/index.tsx | 6 ++++-- app/routes/_app+/settings.profile+/password.tsx | 6 ++++-- app/routes/_app+/settings.profile+/two-factor.disable.tsx | 4 ++-- app/routes/_app+/settings.profile+/two-factor.index.tsx | 6 ++++-- app/routes/_app+/settings.profile+/two-factor.verify.tsx | 7 +++++-- app/routes/_app+/users+/$username.tsx | 2 +- app/utils/auth.server.ts | 2 +- app/utils/client-hints.tsx | 2 +- app/utils/misc.tsx | 2 +- app/utils/misc.use-double-check.test.tsx | 2 +- react-router.config.ts | 2 +- server/app.ts | 2 +- tests/playwright-utils.ts | 2 +- types/deps.d.ts | 4 +++- 28 files changed, 64 insertions(+), 42 deletions(-) diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx index 30c3d40c..66108f32 100644 --- a/app/components/error-boundary.tsx +++ b/app/components/error-boundary.tsx @@ -1,5 +1,5 @@ -import { type ReactElement, useEffect } from 'react' import * as Sentry from '@sentry/react-router' +import { type ReactElement, useEffect } from 'react' import { type ErrorResponse, isRouteErrorResponse, diff --git a/app/components/progress-bar.tsx b/app/components/progress-bar.tsx index 870444fe..eb3ce428 100644 --- a/app/components/progress-bar.tsx +++ b/app/components/progress-bar.tsx @@ -1,5 +1,5 @@ -import { useNavigation } from 'react-router' import { useEffect, useRef, useState } from 'react' +import { useNavigation } from 'react-router' import { useSpinDelay } from 'spin-delay' import { cn } from '#app/utils/misc.tsx' import { Icon } from './ui/icon.tsx' diff --git a/app/components/search-bar.tsx b/app/components/search-bar.tsx index e296f045..aede571d 100644 --- a/app/components/search-bar.tsx +++ b/app/components/search-bar.tsx @@ -1,5 +1,5 @@ -import { Form, useSearchParams, useSubmit } from 'react-router' import { useId } from 'react' +import { Form, useSearchParams, useSubmit } from 'react-router' import { useDebounce, useIsPending } from '#app/utils/misc.tsx' import { Icon } from './ui/icon.tsx' import { Input } from './ui/input.tsx' diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx index ed780dcf..e7a0e349 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -1,7 +1,7 @@ import { Slot } from '@radix-ui/react-slot' -import { Link, type LinkProps } from 'react-router' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' +import { Link, type LinkProps } from 'react-router' import { cn } from '#app/utils/misc.tsx' diff --git a/app/components/user-profile.test.tsx b/app/components/user-profile.test.tsx index a63241c1..890ead14 100644 --- a/app/components/user-profile.test.tsx +++ b/app/components/user-profile.test.tsx @@ -1,8 +1,8 @@ -import { page } from 'vitest/browser' import { type ComponentProps, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { createMemoryRouter, RouterProvider } from 'react-router' import { afterEach, expect, test } from 'vitest' +import { page } from 'vitest/browser' import { UserProfileView } from './user-profile.tsx' let root: Root | null = null diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 9031c420..454bcc6c 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,15 +1,15 @@ import { PassThrough } from 'stream' import { createReadableStreamFromReadable } from '@react-router/node' +import * as Sentry from '@sentry/react-router' +import chalk from 'chalk' +import { isbot } from 'isbot' +import { renderToPipeableStream } from 'react-dom/server' import { type ActionFunctionArgs, type HandleDocumentRequestFunction, type LoaderFunctionArgs, ServerRouter, } from 'react-router' -import * as Sentry from '@sentry/react-router' -import chalk from 'chalk' -import { isbot } from 'isbot' -import { renderToPipeableStream } from 'react-dom/server' import { getSessionRenewal, sessionKey } from './utils/auth.server.ts' import { init as initCron } from './utils/cron.server.ts' import { getEnv, init as initEnv } from './utils/env.server.ts' diff --git a/app/routes/_app+/_layout.tsx b/app/routes/_app+/_layout.tsx index 8a87f80b..a23f118b 100644 --- a/app/routes/_app+/_layout.tsx +++ b/app/routes/_app+/_layout.tsx @@ -1,11 +1,15 @@ +import { useRef } from 'react' import { + Form, + Link, + Outlet, data as json, type HeadersFunction, type LoaderFunctionArgs, type MetaFunction, + useLoaderData, + useSubmit, } from 'react-router' -import { Form, Link, Outlet, useLoaderData, useSubmit } from 'react-router' -import { useRef } from 'react' import { GeneralErrorBoundary } from '#app/components/error-boundary.js' import { Button } from '#app/components/ui/button.tsx' import { diff --git a/app/routes/_app+/admin+/source.tsx b/app/routes/_app+/admin+/source.tsx index 55525367..bcca8571 100644 --- a/app/routes/_app+/admin+/source.tsx +++ b/app/routes/_app+/admin+/source.tsx @@ -2,11 +2,12 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { - type LoaderFunctionArgs, - data as json, type ActionFunctionArgs, + data as json, + type LoaderFunctionArgs, + useFetcher, + useLoaderData, } from 'react-router' -import { useFetcher, useLoaderData } from 'react-router' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.js' import { Field } from '#app/components/forms.js' diff --git a/app/routes/_app+/recipients+/$recipientId.edit.tsx b/app/routes/_app+/recipients+/$recipientId.edit.tsx index db5102b7..ea276a42 100644 --- a/app/routes/_app+/recipients+/$recipientId.edit.tsx +++ b/app/routes/_app+/recipients+/$recipientId.edit.tsx @@ -3,8 +3,8 @@ import { data as json, type LoaderFunctionArgs, type MetaFunction, + useLoaderData, } from 'react-router' -import { useLoaderData } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' diff --git a/app/routes/_app+/recipients+/$recipientId.past.tsx b/app/routes/_app+/recipients+/$recipientId.past.tsx index adf1e68d..cb24a65b 100644 --- a/app/routes/_app+/recipients+/$recipientId.past.tsx +++ b/app/routes/_app+/recipients+/$recipientId.past.tsx @@ -1,10 +1,12 @@ import { invariantResponse } from '@epic-web/invariant' import { - type MetaFunction, + Link, data as json, type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, + useSearchParams, } from 'react-router' -import { Link, useLoaderData, useSearchParams } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { SearchBar } from '#app/components/search-bar.tsx' import { Button } from '#app/components/ui/button.tsx' diff --git a/app/routes/_app+/recipients+/$recipientId.tsx b/app/routes/_app+/recipients+/$recipientId.tsx index 4ca9ed1a..42bd2e6d 100644 --- a/app/routes/_app+/recipients+/$recipientId.tsx +++ b/app/routes/_app+/recipients+/$recipientId.tsx @@ -1,13 +1,14 @@ import { invariantResponse } from '@epic-web/invariant' -import { type LoaderFunctionArgs, type MetaFunction } from 'react-router' +import { useEffect, useRef } from 'react' import { Link, Outlet, data as json, + type LoaderFunctionArgs, + type MetaFunction, useLoaderData, useMatches, } from 'react-router' -import { useEffect, useRef } from 'react' import { GeneralErrorBoundary } from '#app/components/error-boundary.js' import { ButtonLink } from '#app/components/ui/button.tsx' import { Icon } from '#app/components/ui/icon.js' diff --git a/app/routes/_app+/recipients+/__editor.tsx b/app/routes/_app+/recipients+/__editor.tsx index bd2d427c..177a1cc5 100644 --- a/app/routes/_app+/recipients+/__editor.tsx +++ b/app/routes/_app+/recipients+/__editor.tsx @@ -5,8 +5,8 @@ import { useForm, } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { Form, useActionData, useFetcher } from 'react-router' import { useState } from 'react' +import { Form, useActionData, useFetcher } from 'react-router' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field, SelectField } from '#app/components/forms.tsx' diff --git a/app/routes/_app+/recipients+/new.tsx b/app/routes/_app+/recipients+/new.tsx index e962ba0b..79dc6dd8 100644 --- a/app/routes/_app+/recipients+/new.tsx +++ b/app/routes/_app+/recipients+/new.tsx @@ -1,10 +1,10 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { - type MetaFunction, data as json, type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, } from 'react-router' -import { useLoaderData } from 'react-router' import { requireUserId } from '#app/utils/auth.server.ts' import { RecipientEditor } from './__editor.tsx' diff --git a/app/routes/_app+/settings.profile+/change-number.tsx b/app/routes/_app+/settings.profile+/change-number.tsx index b27eb37c..5b5f8ab4 100644 --- a/app/routes/_app+/settings.profile+/change-number.tsx +++ b/app/routes/_app+/settings.profile+/change-number.tsx @@ -2,12 +2,15 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { + Form, + Link, data as json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, + useActionData, + useLoaderData, } from 'react-router' -import { Form, Link, useActionData, useLoaderData } from 'react-router' import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { Button } from '#app/components/ui/button.tsx' diff --git a/app/routes/_app+/settings.profile+/index.tsx b/app/routes/_app+/settings.profile+/index.tsx index 4145bf1c..1e5c11cc 100644 --- a/app/routes/_app+/settings.profile+/index.tsx +++ b/app/routes/_app+/settings.profile+/index.tsx @@ -3,11 +3,13 @@ import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { + Link, data as json, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, + useFetcher, + useLoaderData, } from 'react-router' -import { Link, useFetcher, useLoaderData } from 'react-router' import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { ButtonLink } from '#app/components/ui/button.tsx' diff --git a/app/routes/_app+/settings.profile+/password.tsx b/app/routes/_app+/settings.profile+/password.tsx index 97878737..adb56db3 100644 --- a/app/routes/_app+/settings.profile+/password.tsx +++ b/app/routes/_app+/settings.profile+/password.tsx @@ -2,11 +2,13 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' import { + Form, + Link, data as json, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, + useActionData, } from 'react-router' -import { Form, Link, useActionData } from 'react-router' import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { Button } from '#app/components/ui/button.tsx' diff --git a/app/routes/_app+/settings.profile+/two-factor.disable.tsx b/app/routes/_app+/settings.profile+/two-factor.disable.tsx index a061ff6b..2cae18a9 100644 --- a/app/routes/_app+/settings.profile+/two-factor.disable.tsx +++ b/app/routes/_app+/settings.profile+/two-factor.disable.tsx @@ -1,10 +1,10 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { data as json, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, + useFetcher, } from 'react-router' -import { useFetcher } from 'react-router' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireRecentVerification } from '#app/routes/_app+/_auth+/verify.server.ts' diff --git a/app/routes/_app+/settings.profile+/two-factor.index.tsx b/app/routes/_app+/settings.profile+/two-factor.index.tsx index 834bf075..012e964b 100644 --- a/app/routes/_app+/settings.profile+/two-factor.index.tsx +++ b/app/routes/_app+/settings.profile+/two-factor.index.tsx @@ -1,11 +1,13 @@ import { type SEOHandle } from '@nasa-gcn/remix-seo' import { + Link, data as json, redirect, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, + useFetcher, + useLoaderData, } from 'react-router' -import { Link, useFetcher, useLoaderData } from 'react-router' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireUserId } from '#app/utils/auth.server.ts' diff --git a/app/routes/_app+/settings.profile+/two-factor.verify.tsx b/app/routes/_app+/settings.profile+/two-factor.verify.tsx index d7901476..8971b736 100644 --- a/app/routes/_app+/settings.profile+/two-factor.verify.tsx +++ b/app/routes/_app+/settings.profile+/two-factor.verify.tsx @@ -1,14 +1,17 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' +import * as QRCode from 'qrcode' import { + Form, data as json, redirect, type LoaderFunctionArgs, type ActionFunctionArgs, + useActionData, + useLoaderData, + useNavigation, } from 'react-router' -import { Form, useActionData, useLoaderData, useNavigation } from 'react-router' -import * as QRCode from 'qrcode' import { z } from 'zod' import { ErrorList, OTPField } from '#app/components/forms.tsx' import { Icon } from '#app/components/ui/icon.tsx' diff --git a/app/routes/_app+/users+/$username.tsx b/app/routes/_app+/users+/$username.tsx index b869a909..49bd8463 100644 --- a/app/routes/_app+/users+/$username.tsx +++ b/app/routes/_app+/users+/$username.tsx @@ -5,10 +5,10 @@ import { type MetaFunction, } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' -import { prisma } from '#app/utils/db.server.ts' import UserProfile, { type UserProfileLoaderData, } from '#app/components/user-profile.tsx' +import { prisma } from '#app/utils/db.server.ts' export async function loader({ params }: LoaderFunctionArgs) { const user = await prisma.user.findFirst({ diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 54da18cf..b7f2b1d8 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -1,5 +1,5 @@ -import { redirect } from 'react-router' import bcrypt from 'bcryptjs' +import { redirect } from 'react-router' import { safeRedirect } from 'remix-utils/safe-redirect' import { type Password, diff --git a/app/utils/client-hints.tsx b/app/utils/client-hints.tsx index 3a1ba1fc..98559c1e 100644 --- a/app/utils/client-hints.tsx +++ b/app/utils/client-hints.tsx @@ -7,8 +7,8 @@ import { clientHint as colorSchemeHint, subscribeToSchemeChange, } from '@epic-web/client-hints/color-scheme' -import { useRevalidator } from 'react-router' import * as React from 'react' +import { useRevalidator } from 'react-router' import { useRequestInfo } from './request-info.ts' const hintsUtils = getHintUtils({ diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx index c8b3c570..17e4aad3 100644 --- a/app/utils/misc.tsx +++ b/app/utils/misc.tsx @@ -1,6 +1,6 @@ -import { useFormAction, useNavigation } from 'react-router' import { clsx, type ClassValue } from 'clsx' import { useEffect, useMemo, useRef, useState } from 'react' +import { useFormAction, useNavigation } from 'react-router' import { useSpinDelay } from 'spin-delay' import { extendTailwindMerge } from 'tailwind-merge' import { extendedTheme } from './extended-theme.ts' diff --git a/app/utils/misc.use-double-check.test.tsx b/app/utils/misc.use-double-check.test.tsx index d619d9bb..c3ab1ddc 100644 --- a/app/utils/misc.use-double-check.test.tsx +++ b/app/utils/misc.use-double-check.test.tsx @@ -1,7 +1,7 @@ -import { page, userEvent } from 'vitest/browser' import { useState, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, expect, test } from 'vitest' +import { page, userEvent } from 'vitest/browser' import { useDoubleCheck } from './misc.tsx' let root: Root | null = null diff --git a/react-router.config.ts b/react-router.config.ts index c9229f61..9cbd5da6 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -1,4 +1,4 @@ -import type { Config } from '@react-router/dev/config' +import { type Config } from '@react-router/dev/config' export default { serverModuleFormat: 'esm', diff --git a/server/app.ts b/server/app.ts index d9a009b7..9b3fc3ff 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,6 +1,6 @@ import { createRequestHandler } from '@react-router/express' -import { type ServerBuild } from 'react-router' import express from 'express' +import { type ServerBuild } from 'react-router' declare module 'react-router' { interface AppLoadContext { diff --git a/tests/playwright-utils.ts b/tests/playwright-utils.ts index f3267663..2efbcd0f 100644 --- a/tests/playwright-utils.ts +++ b/tests/playwright-utils.ts @@ -9,7 +9,7 @@ import { prisma } from '#app/utils/db.server.ts' import { type User as UserModel } from '#app/utils/prisma-generated.server/client.ts' import { authSessionStorage } from '#app/utils/session.server.ts' import { createUser } from './db-utils.ts' -import { deleteText, waitFor } from './mocks/utils.ts' +import { deleteText } from './mocks/utils.ts' export * from './db-utils.ts' export { waitFor } from './mocks/utils.ts' diff --git a/types/deps.d.ts b/types/deps.d.ts index 04a7ef5e..9ae32afb 100644 --- a/types/deps.d.ts +++ b/types/deps.d.ts @@ -6,6 +6,8 @@ // } declare module 'virtual:react-router/server-build' { - const build: import('react-router').ServerBuild + import type { ServerBuild } from 'react-router' + + const build: ServerBuild export = build } From 99a072dcdda7b47828def3f9abd4b8362d363d0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 02:06:06 +0000 Subject: [PATCH 15/17] Adjust deps type import style Co-authored-by: me --- types/deps.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/deps.d.ts b/types/deps.d.ts index 9ae32afb..a909b22e 100644 --- a/types/deps.d.ts +++ b/types/deps.d.ts @@ -6,7 +6,7 @@ // } declare module 'virtual:react-router/server-build' { - import type { ServerBuild } from 'react-router' + import { type ServerBuild } from 'react-router' const build: ServerBuild export = build From e90b7907554c8fb8c62bad6309c4b090bb28e94b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 02:20:52 +0000 Subject: [PATCH 16/17] Fix vitest config and cache Playwright Co-authored-by: me --- .github/workflows/deploy.yml | 19 +++++++++++++++++++ vitest.config.ts | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3f9b76e7..2ef1a952 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -72,6 +72,17 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 + - name: 🎭 Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: 🎭 Install Playwright browsers + run: npm run test:e2e:install + - name: 🏄 Copy test env vars run: cp .env.example .env @@ -100,6 +111,14 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 + - name: 🎭 Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: 📥 Install Playwright Browsers run: npm run test:e2e:install diff --git a/vitest.config.ts b/vitest.config.ts index e7d9beb4..a5fe504c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,9 +17,6 @@ export default defineConfig({ setupFiles: ['./tests/setup/setup-test-env.ts'], globalSetup: ['./tests/setup/global-setup.ts'], restoreMocks: true, - coverage: { - include: ['app/**/*.{ts,tsx}'], - }, }, }, { @@ -39,5 +36,8 @@ export default defineConfig({ }, }, ], + coverage: { + include: ['app/**/*.{ts,tsx}'], + }, }, }) From 82d43f077555aa556cea46a1595e2d6da3631f62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 02:22:02 +0000 Subject: [PATCH 17/17] Fix vitest project naming types Co-authored-by: me --- vitest.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index a5fe504c..942ba925 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,9 +10,9 @@ export default defineConfig({ test: { projects: [ { - name: 'node', extends: true, test: { + name: 'node', include: ['./app/**/*.test.ts'], setupFiles: ['./tests/setup/setup-test-env.ts'], globalSetup: ['./tests/setup/global-setup.ts'], @@ -20,9 +20,9 @@ export default defineConfig({ }, }, { - name: 'browser', extends: true, test: { + name: 'browser', include: ['./app/**/*.test.tsx'], setupFiles: ['./tests/setup/setup-browser-env.ts'], restoreMocks: true,