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/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 new file mode 100644 index 00000000..890ead14 --- /dev/null +++ b/app/components/user-profile.test.tsx @@ -0,0 +1,85 @@ +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 +let container: HTMLDivElement | null = null + +const render = (ui: ReactElement) => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + root.render(ui) +} + +const renderProfile = (props: ComponentProps) => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + ], + { initialEntries: ['/'] }, + ) + + render() +} + +afterEach(() => { + root?.unmount() + root = null + container?.remove() + container = null +}) + +test('The user profile when not logged in as self', async () => { + const user = { + id: 'user_1', + username: 'harry', + name: 'Harry Example', + } + + renderProfile({ + user, + userJoinedDisplay: 'Jan 1, 2024', + isLoggedInUser: false, + }) + + 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 = { + id: 'user_2', + username: 'logan', + name: 'Logan Example', + } + + renderProfile({ + user, + userJoinedDisplay: 'Jan 1, 2024', + isLoggedInUser: true, + }) + + 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/components/user-profile.tsx b/app/components/user-profile.tsx new file mode 100644 index 00000000..90dac58d --- /dev/null +++ b/app/components/user-profile.tsx @@ -0,0 +1,89 @@ +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 UserProfileUser = { + id: string + name: string | null + username: string +} + +export type UserProfileLoaderData = { + user: UserProfileUser + userJoinedDisplay: string +} + +export type UserProfileViewProps = { + user: UserProfileUser + userJoinedDisplay: string + isLoggedInUser: boolean +} + +export function UserProfileView({ + user, + userJoinedDisplay, + isLoggedInUser, +}: UserProfileViewProps) { + const userDisplayName = user.name ?? user.username + + return ( +
+ +
+
+

{userDisplayName}

+
+

+ Joined {userJoinedDisplay} +

+ {isLoggedInUser ? ( +
+ +
+ ) : null} +
+ {isLoggedInUser ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} + +export default function UserProfile() { + const data = useLoaderData() + const loggedInUser = useOptionalUser() + const isLoggedInUser = data.user.id === loggedInUser?.id + + return ( + + ) +} 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.test.tsx b/app/routes/_app+/users+/$username.test.tsx deleted file mode 100644 index 3c432947..00000000 --- a/app/routes/_app+/users+/$username.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @vitest-environment jsdom - */ -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' - -type RootLoaderArgs = Parameters[0] -type UsernameLoaderArgs = Parameters[0] - -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([ - { - id: 'routes/users.$username', - path: '/users/:username', - Component: UsernameRoute, - loader, - }, - ]) - - const routeUrl = `/users/${user.username}` - render() - - await screen.findByRole('heading', { level: 1, name: user.name! }) - await screen.findByRole('link', { name: `${user.name}'s recipients` }) -}) - -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 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 routeUrl = `/users/${user.username}` - await 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 }) -}) diff --git a/app/routes/_app+/users+/$username.tsx b/app/routes/_app+/users+/$username.tsx index 34836f8c..49bd8463 100644 --- a/app/routes/_app+/users+/$username.tsx +++ b/app/routes/_app+/users+/$username.tsx @@ -2,17 +2,13 @@ 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 UserProfile, { + type UserProfileLoaderData, +} from '#app/components/user-profile.tsx' import { prisma } from '#app/utils/db.server.ts' -import { useOptionalUser } from '#app/utils/user.ts' export async function loader({ params }: LoaderFunctionArgs) { const user = await prisma.user.findFirst({ @@ -29,60 +25,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/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 6bc20f3b..c3ab1ddc 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 { 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 +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/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/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/types/deps.d.ts b/types/deps.d.ts index 04a7ef5e..a909b22e 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 } diff --git a/vitest.config.ts b/vitest.config.ts index 21a48cf9..942ba925 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,16 +1,41 @@ /// 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, + projects: [ + { + extends: true, + test: { + name: 'node', + include: ['./app/**/*.test.ts'], + setupFiles: ['./tests/setup/setup-test-env.ts'], + globalSetup: ['./tests/setup/global-setup.ts'], + restoreMocks: true, + }, + }, + { + extends: true, + test: { + name: 'browser', + include: ['./app/**/*.test.tsx'], + setupFiles: ['./tests/setup/setup-browser-env.ts'], + restoreMocks: true, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + ui: false, + }, + }, + }, + ], coverage: { include: ['app/**/*.{ts,tsx}'], },