From 927913535528acc572000c81a397e49276574346 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 25 Apr 2025 23:40:15 -0700 Subject: [PATCH] feat: support guest session (#919) --- app/(auth)/api/auth/guest/route.ts | 21 +++ app/(auth)/auth.config.ts | 28 +--- app/(auth)/auth.ts | 55 ++++--- app/(auth)/login/page.tsx | 4 + app/(auth)/register/page.tsx | 4 + app/(chat)/api/chat/route.ts | 20 ++- app/(chat)/chat/[id]/page.tsx | 14 +- app/(chat)/page.tsx | 10 ++ app/layout.tsx | 3 +- components/chat-header.tsx | 4 + components/chat.tsx | 13 +- components/model-selector.tsx | 20 ++- components/sidebar-toggle.tsx | 1 + components/sidebar-user-nav.tsx | 78 +++++++--- components/toast.tsx | 37 ++++- lib/ai/entitlements.ts | 29 ++++ lib/ai/models.ts | 2 +- lib/constants.ts | 4 +- lib/db/queries.ts | 48 +++++++ middleware.ts | 58 +++++++- package.json | 2 +- playwright.config.ts | 50 ++----- tests/e2e/artifacts.test.ts | 10 +- tests/e2e/auth.setup.ts | 24 ---- tests/e2e/auth.test.ts | 77 ---------- tests/e2e/chat.test.ts | 28 ++-- tests/e2e/reasoning.setup.ts | 20 --- tests/e2e/reasoning.test.ts | 12 +- tests/e2e/session.test.ts | 207 +++++++++++++++++++++++++++ tests/fixtures.ts | 27 +++- tests/{auth-helper.ts => helpers.ts} | 29 +++- tests/pages/auth.ts | 65 +++++++++ tests/pages/chat.ts | 15 +- tests/routes/chat.test.ts | 10 +- 34 files changed, 741 insertions(+), 288 deletions(-) create mode 100644 app/(auth)/api/auth/guest/route.ts create mode 100644 lib/ai/entitlements.ts delete mode 100644 tests/e2e/auth.setup.ts delete mode 100644 tests/e2e/auth.test.ts delete mode 100644 tests/e2e/reasoning.setup.ts create mode 100644 tests/e2e/session.test.ts rename tests/{auth-helper.ts => helpers.ts} (62%) create mode 100644 tests/pages/auth.ts diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 0000000..25af1fa --- /dev/null +++ b/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,21 @@ +import { signIn } from '@/app/(auth)/auth'; +import { isDevelopmentEnvironment } from '@/lib/constants'; +import { getToken } from 'next-auth/jwt'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const redirectUrl = searchParams.get('redirectUrl') || '/'; + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (token) { + return NextResponse.redirect(new URL('/', request.url)); + } + + return signIn('guest', { redirect: true, redirectTo: redirectUrl }); +} diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts index cf1ecdd..b7d7d50 100644 --- a/app/(auth)/auth.config.ts +++ b/app/(auth)/auth.config.ts @@ -9,31 +9,5 @@ export const authConfig = { // added later in auth.ts since it requires bcrypt which is only compatible with Node.js // while this file is also used in non-Node.js environments ], - callbacks: { - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isOnChat = nextUrl.pathname.startsWith('/'); - const isOnRegister = nextUrl.pathname.startsWith('/register'); - const isOnLogin = nextUrl.pathname.startsWith('/login'); - - if (isLoggedIn && (isOnLogin || isOnRegister)) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); - } - - if (isOnRegister || isOnLogin) { - return true; // Always allow access to register and login pages - } - - if (isOnChat) { - if (isLoggedIn) return true; - return false; // Redirect unauthenticated users to login page - } - - if (isLoggedIn) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); - } - - return true; - }, - }, + callbacks: {}, } satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index 8440ad8..9185248 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -1,14 +1,33 @@ import { compare } from 'bcrypt-ts'; -import NextAuth, { type User, type Session } from 'next-auth'; +import NextAuth, { type DefaultSession } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; - -import { getUser } from '@/lib/db/queries'; - +import { createGuestUser, getUser } from '@/lib/db/queries'; import { authConfig } from './auth.config'; import { DUMMY_PASSWORD } from '@/lib/constants'; +import type { DefaultJWT } from 'next-auth/jwt'; -interface ExtendedSession extends Session { - user: User; +export type UserType = 'guest' | 'regular'; + +declare module 'next-auth' { + interface Session extends DefaultSession { + user: { + id: string; + type: UserType; + } & DefaultSession['user']; + } + + interface User { + id?: string; + email?: string | null; + type: UserType; + } +} + +declare module 'next-auth/jwt' { + interface JWT extends DefaultJWT { + id: string; + type: UserType; + } } export const { @@ -40,27 +59,31 @@ export const { if (!passwordsMatch) return null; - return user as any; + return { ...user, type: 'regular' }; + }, + }), + Credentials({ + id: 'guest', + credentials: {}, + async authorize() { + const [guestUser] = await createGuestUser(); + return { ...guestUser, type: 'guest' }; }, }), ], callbacks: { async jwt({ token, user }) { if (user) { - token.id = user.id; + token.id = user.id as string; + token.type = user.type; } return token; }, - async session({ - session, - token, - }: { - session: ExtendedSession; - token: any; - }) { + async session({ session, token }) { if (session.user) { - session.user.id = token.id as string; + session.user.id = token.id; + session.user.type = token.type; } return session; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ba987c2..33e9e82 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -9,6 +9,7 @@ import { AuthForm } from '@/components/auth-form'; import { SubmitButton } from '@/components/submit-button'; import { login, type LoginActionState } from '../actions'; +import { useSession } from 'next-auth/react'; export default function Page() { const router = useRouter(); @@ -23,6 +24,8 @@ export default function Page() { }, ); + const { update: updateSession } = useSession(); + useEffect(() => { if (state.status === 'failed') { toast({ @@ -36,6 +39,7 @@ export default function Page() { }); } else if (state.status === 'success') { setIsSuccessful(true); + updateSession(); router.refresh(); } }, [state.status]); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 649278e..ab2ee82 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -9,6 +9,7 @@ import { SubmitButton } from '@/components/submit-button'; import { register, type RegisterActionState } from '../actions'; import { toast } from '@/components/toast'; +import { useSession } from 'next-auth/react'; export default function Page() { const router = useRouter(); @@ -23,6 +24,8 @@ export default function Page() { }, ); + const { update: updateSession } = useSession(); + useEffect(() => { if (state.status === 'user_exists') { toast({ type: 'error', description: 'Account already exists!' }); @@ -37,6 +40,7 @@ export default function Page() { toast({ type: 'success', description: 'Account created successfully!' }); setIsSuccessful(true); + updateSession(); router.refresh(); } }, [state]); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 2b3cffd..1915f33 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -5,11 +5,12 @@ import { smoothStream, streamText, } from 'ai'; -import { auth } from '@/app/(auth)/auth'; +import { auth, type UserType } from '@/app/(auth)/auth'; import { systemPrompt } from '@/lib/ai/prompts'; import { deleteChatById, getChatById, + getMessageCountByUserId, saveChat, saveMessages, } from '@/lib/db/queries'; @@ -25,6 +26,7 @@ import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; import { getWeather } from '@/lib/ai/tools/get-weather'; import { isProductionEnvironment } from '@/lib/constants'; import { myProvider } from '@/lib/ai/providers'; +import { entitlementsByUserType } from '@/lib/ai/entitlements'; export const maxDuration = 60; @@ -46,6 +48,22 @@ export async function POST(request: Request) { return new Response('Unauthorized', { status: 401 }); } + const userType: UserType = session.user.type; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 24, + }); + + if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { + return new Response( + 'You have exceeded your maximum number of messages for the day! Please try again later.', + { + status: 429, + }, + ); + } + const userMessage = getMostRecentUserMessage(messages); if (!userMessage) { diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 66c027a..8af21d0 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -1,13 +1,13 @@ import { cookies } from 'next/headers'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import { auth } from '@/app/(auth)/auth'; import { Chat } from '@/components/chat'; import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; import { DataStreamHandler } from '@/components/data-stream-handler'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; -import { DBMessage } from '@/lib/db/schema'; -import { Attachment, UIMessage } from 'ai'; +import type { DBMessage } from '@/lib/db/schema'; +import type { Attachment, UIMessage } from 'ai'; export default async function Page(props: { params: Promise<{ id: string }> }) { const params = await props.params; @@ -20,8 +20,12 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { const session = await auth(); + if (!session) { + redirect('/api/auth/guest'); + } + if (chat.visibility === 'private') { - if (!session || !session.user) { + if (!session.user) { return notFound(); } @@ -59,6 +63,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { selectedChatModel={DEFAULT_CHAT_MODEL} selectedVisibilityType={chat.visibility} isReadonly={session?.user?.id !== chat.userId} + session={session} /> @@ -73,6 +78,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { selectedChatModel={chatModelFromCookie.value} selectedVisibilityType={chat.visibility} isReadonly={session?.user?.id !== chat.userId} + session={session} /> diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 9fbc743..09ff2e6 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -4,8 +4,16 @@ import { Chat } from '@/components/chat'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; import { generateUUID } from '@/lib/utils'; import { DataStreamHandler } from '@/components/data-stream-handler'; +import { auth } from '../(auth)/auth'; +import { redirect } from 'next/navigation'; export default async function Page() { + const session = await auth(); + + if (!session) { + redirect('/api/auth/guest'); + } + const id = generateUUID(); const cookieStore = await cookies(); @@ -21,6 +29,7 @@ export default async function Page() { selectedChatModel={DEFAULT_CHAT_MODEL} selectedVisibilityType="private" isReadonly={false} + session={session} /> @@ -36,6 +45,7 @@ export default async function Page() { selectedChatModel={modelIdFromCookie.value} selectedVisibilityType="private" isReadonly={false} + session={session} /> diff --git a/app/layout.tsx b/app/layout.tsx index 8a6ad5d..1813e0f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google'; import { ThemeProvider } from '@/components/theme-provider'; import './globals.css'; +import { SessionProvider } from 'next-auth/react'; export const metadata: Metadata = { metadataBase: new URL('https://chat.vercel.ai'), @@ -77,7 +78,7 @@ export default async function RootLayout({ disableTransitionOnChange > - {children} + {children} diff --git a/components/chat-header.tsx b/components/chat-header.tsx index 64df8b5..117e828 100644 --- a/components/chat-header.tsx +++ b/components/chat-header.tsx @@ -12,17 +12,20 @@ import { useSidebar } from './ui/sidebar'; import { memo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { type VisibilityType, VisibilitySelector } from './visibility-selector'; +import type { Session } from 'next-auth'; function PureChatHeader({ chatId, selectedModelId, selectedVisibilityType, isReadonly, + session, }: { chatId: string; selectedModelId: string; selectedVisibilityType: VisibilityType; isReadonly: boolean; + session: Session; }) { const router = useRouter(); const { open } = useSidebar(); @@ -54,6 +57,7 @@ function PureChatHeader({ {!isReadonly && ( diff --git a/components/chat.tsx b/components/chat.tsx index 2a919b1..075ced2 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -12,9 +12,10 @@ import { MultimodalInput } from './multimodal-input'; import { Messages } from './messages'; import type { VisibilityType } from './visibility-selector'; import { useArtifactSelector } from '@/hooks/use-artifact'; -import { toast } from 'sonner'; import { unstable_serialize } from 'swr/infinite'; import { getChatHistoryPaginationKey } from './sidebar-history'; +import { toast } from './toast'; +import type { Session } from 'next-auth'; export function Chat({ id, @@ -22,12 +23,14 @@ export function Chat({ selectedChatModel, selectedVisibilityType, isReadonly, + session, }: { id: string; initialMessages: Array; selectedChatModel: string; selectedVisibilityType: VisibilityType; isReadonly: boolean; + session: Session; }) { const { mutate } = useSWRConfig(); @@ -51,8 +54,11 @@ export function Chat({ onFinish: () => { mutate(unstable_serialize(getChatHistoryPaginationKey)); }, - onError: () => { - toast.error('An error occurred, please try again!'); + onError: (error) => { + toast({ + type: 'error', + description: error.message, + }); }, }); @@ -72,6 +78,7 @@ export function Chat({ selectedModelId={selectedChatModel} selectedVisibilityType={selectedVisibilityType} isReadonly={isReadonly} + session={session} /> ) { const [open, setOpen] = useState(false); const [optimisticModelId, setOptimisticModelId] = useOptimistic(selectedModelId); + const userType = session.user.type; + const { availableChatModelIds } = entitlementsByUserType[userType]; + + const availableChatModels = chatModels.filter((chatModel) => + availableChatModelIds.includes(chatModel.id), + ); + const selectedChatModel = useMemo( - () => chatModels.find((chatModel) => chatModel.id === optimisticModelId), - [optimisticModelId], + () => + availableChatModels.find( + (chatModel) => chatModel.id === optimisticModelId, + ), + [optimisticModelId, availableChatModels], ); return ( @@ -49,7 +63,7 @@ export function ModelSelector({ - {chatModels.map((chatModel) => { + {availableChatModels.map((chatModel) => { const { id } = chatModel; return ( diff --git a/components/sidebar-toggle.tsx b/components/sidebar-toggle.tsx index d9f701f..83f4767 100644 --- a/components/sidebar-toggle.tsx +++ b/components/sidebar-toggle.tsx @@ -19,6 +19,7 @@ export function SidebarToggle({ diff --git a/components/toast.tsx b/components/toast.tsx index 4ad93a8..5e94854 100644 --- a/components/toast.tsx +++ b/components/toast.tsx @@ -1,8 +1,9 @@ 'use client'; -import React, { ReactNode } from 'react'; +import React, { useEffect, useRef, useState, type ReactNode } from 'react'; import { toast as sonnerToast } from 'sonner'; import { CheckCircleFillIcon, WarningIcon } from './icons'; +import { cn } from '@/lib/utils'; const iconsByType: Record<'success' | 'error', ReactNode> = { success: , @@ -18,20 +19,48 @@ export function toast(props: Omit) { function Toast(props: ToastProps) { const { id, type, description } = props; + const descriptionRef = useRef(null); + const [multiLine, setMultiLine] = useState(false); + + useEffect(() => { + const el = descriptionRef.current; + if (!el) return; + + const update = () => { + const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight); + const lines = Math.round(el.scrollHeight / lineHeight); + setMultiLine(lines > 1); + }; + + update(); // initial check + const ro = new ResizeObserver(update); // re-check on width changes + ro.observe(el); + + return () => ro.disconnect(); + }, [description]); + return (
{iconsByType[type]}
-
{description}
+
+ {description} +
); diff --git a/lib/ai/entitlements.ts b/lib/ai/entitlements.ts new file mode 100644 index 0000000..2fba57a --- /dev/null +++ b/lib/ai/entitlements.ts @@ -0,0 +1,29 @@ +import type { UserType } from '@/app/(auth)/auth'; +import type { ChatModel } from './models'; + +interface Entitlements { + maxMessagesPerDay: number; + availableChatModelIds: Array; +} + +export const entitlementsByUserType: Record = { + /* + * For users without an account + */ + guest: { + maxMessagesPerDay: 20, + availableChatModelIds: ['chat-model', 'chat-model-reasoning'], + }, + + /* + * For users with an account + */ + regular: { + maxMessagesPerDay: 100, + availableChatModelIds: ['chat-model', 'chat-model-reasoning'], + }, + + /* + * TODO: For users with an account and a paid membership + */ +}; diff --git a/lib/ai/models.ts b/lib/ai/models.ts index ab3e471..91fb137 100644 --- a/lib/ai/models.ts +++ b/lib/ai/models.ts @@ -1,6 +1,6 @@ export const DEFAULT_CHAT_MODEL: string = 'chat-model'; -interface ChatModel { +export interface ChatModel { id: string; name: string; description: string; diff --git a/lib/constants.ts b/lib/constants.ts index d0b573b..136d18d 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,11 +1,13 @@ import { generateDummyPassword } from './db/utils'; export const isProductionEnvironment = process.env.NODE_ENV === 'production'; - +export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development'; export const isTestEnvironment = Boolean( process.env.PLAYWRIGHT_TEST_BASE_URL || process.env.PLAYWRIGHT || process.env.CI_PLAYWRIGHT, ); +export const guestRegex = /^guest-\d+$/; + export const DUMMY_PASSWORD = generateDummyPassword(); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 911bc73..7f812ba 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -3,6 +3,7 @@ import 'server-only'; import { and, asc, + count, desc, eq, gt, @@ -27,6 +28,7 @@ import { type Chat, } from './schema'; import type { ArtifactKind } from '@/components/artifact'; +import { generateUUID } from '../utils'; import { generateHashedPassword } from './utils'; // Optionally, if not using email/pass login, you can @@ -57,6 +59,21 @@ export async function createUser(email: string, password: string) { } } +export async function createGuestUser() { + const email = `guest-${Date.now()}`; + const password = generateHashedPassword(generateUUID()); + + try { + return await db.insert(user).values({ email, password }).returning({ + id: user.id, + email: user.email, + }); + } catch (error) { + console.error('Failed to create guest user in database'); + throw error; + } +} + export async function saveChat({ id, userId, @@ -422,3 +439,34 @@ export async function updateChatVisiblityById({ throw error; } } + +export async function getMessageCountByUserId({ + id, + differenceInHours, +}: { id: string; differenceInHours: number }) { + try { + const twentyFourHoursAgo = new Date( + Date.now() - differenceInHours * 60 * 60 * 1000, + ); + + const [stats] = await db + .select({ count: count(message.id) }) + .from(message) + .innerJoin(chat, eq(message.chatId, chat.id)) + .where( + and( + eq(chat.userId, id), + gte(message.createdAt, twentyFourHoursAgo), + eq(message.role, 'user'), + ), + ) + .execute(); + + return stats?.count ?? 0; + } catch (error) { + console.error( + 'Failed to get message count by user id for the last 24 hours from database', + ); + throw error; + } +} diff --git a/middleware.ts b/middleware.ts index 1dd8458..200f802 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,9 +1,59 @@ -import NextAuth from 'next-auth'; +import { NextResponse, type NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { guestRegex, isDevelopmentEnvironment } from './lib/constants'; -import { authConfig } from '@/app/(auth)/auth.config'; +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; -export default NextAuth(authConfig).auth; + /* + * Playwright starts the dev server and requires a 200 status to + * begin the tests, so this ensures that the tests can start + */ + if (pathname.startsWith('/ping')) { + return new Response('pong', { status: 200 }); + } + + if (pathname.startsWith('/api/auth')) { + return NextResponse.next(); + } + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (!token) { + const redirectUrl = encodeURIComponent(request.url); + + return NextResponse.redirect( + new URL(`/api/auth/guest?redirectUrl=${redirectUrl}`, request.url), + ); + } + + const isGuest = guestRegex.test(token?.email ?? ''); + + if (token && !isGuest && ['/login', '/register'].includes(pathname)) { + return NextResponse.redirect(new URL('/', request.url)); + } + + return NextResponse.next(); +} export const config = { - matcher: ['/', '/:id', '/api/:path*', '/login', '/register'], + matcher: [ + '/', + '/chat/:id', + '/api/:path*', + '/login', + '/register', + + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', + ], }; diff --git a/package.json b/package.json index 76beb44..e1de221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-chatbot", - "version": "3.0.6", + "version": "3.0.7", "private": true, "scripts": { "dev": "next dev --turbo", diff --git a/playwright.config.ts b/playwright.config.ts index cb02180..0556035 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,9 +29,9 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 1, + retries: 0, /* Opt out of parallel tests on CI. */ - workers: 1, + workers: process.env.CI ? 2 : 8, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -40,61 +40,27 @@ export default defineConfig({ baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: 'retain-on-failure', }, /* Configure global timeout for each test */ - timeout: 60 * 1000, // 30 seconds + timeout: 120 * 1000, // 120 seconds expect: { - timeout: 60 * 1000, + timeout: 120 * 1000, }, /* Configure projects */ projects: [ { - name: 'setup:auth', - testMatch: /e2e\/auth.setup.ts/, - }, - { - name: 'setup:reasoning', - testMatch: /e2e\/reasoning.setup.ts/, - dependencies: ['setup:auth'], + name: 'e2e', + testMatch: /e2e\/.*.test.ts/, use: { ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/session.json', - }, - }, - { - name: 'chat', - testMatch: /e2e\/chat.test.ts/, - dependencies: ['setup:auth'], - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/session.json', - }, - }, - { - name: 'reasoning', - testMatch: /e2e\/reasoning.test.ts/, - dependencies: ['setup:reasoning'], - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.reasoning/session.json', - }, - }, - { - name: 'artifacts', - testMatch: /e2e\/artifacts.test.ts/, - dependencies: ['setup:auth'], - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/session.json', }, }, { name: 'routes', testMatch: /routes\/.*.test.ts/, - dependencies: [], use: { ...devices['Desktop Chrome'], }, @@ -134,7 +100,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { command: 'pnpm dev', - url: baseURL, + url: `${baseURL}/ping`, timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, diff --git a/tests/e2e/artifacts.test.ts b/tests/e2e/artifacts.test.ts index e6fc3cc..00de579 100644 --- a/tests/e2e/artifacts.test.ts +++ b/tests/e2e/artifacts.test.ts @@ -1,8 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; import { ChatPage } from '../pages/chat'; import { ArtifactPage } from '../pages/artifact'; -test.describe('artifacts activity', () => { +test.describe('Artifacts activity', () => { let chatPage: ChatPage; let artifactPage: ArtifactPage; @@ -13,7 +13,7 @@ test.describe('artifacts activity', () => { await chatPage.createNewChat(); }); - test('create a text artifact', async () => { + test('Create a text artifact', async () => { await chatPage.createNewChat(); await chatPage.sendUserMessage( @@ -31,7 +31,7 @@ test.describe('artifacts activity', () => { await chatPage.hasChatIdInUrl(); }); - test('toggle artifact visibility', async () => { + test('Toggle artifact visibility', async () => { await chatPage.createNewChat(); await chatPage.sendUserMessage( @@ -50,7 +50,7 @@ test.describe('artifacts activity', () => { await chatPage.isElementNotVisible('artifact'); }); - test('send follow up message after generation', async () => { + test('Send follow up message after generation', async () => { await chatPage.createNewChat(); await chatPage.sendUserMessage( diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts deleted file mode 100644 index 2ce63e4..0000000 --- a/tests/e2e/auth.setup.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'node:path'; -import { generateId } from 'ai'; -import { getUnixTime } from 'date-fns'; -import { expect, test as setup } from '@playwright/test'; - -const authFile = path.join(__dirname, '../../playwright/.auth/session.json'); - -setup('authenticate', async ({ page }) => { - const testEmail = `test-${getUnixTime(new Date())}@playwright.com`; - const testPassword = generateId(16); - - await page.goto('http://localhost:3000/register'); - await page.getByPlaceholder('user@acme.com').click(); - await page.getByPlaceholder('user@acme.com').fill(testEmail); - await page.getByLabel('Password').click(); - await page.getByLabel('Password').fill(testPassword); - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByTestId('toast')).toContainText( - 'Account created successfully!', - ); - - await page.context().storageState({ path: authFile }); -}); diff --git a/tests/e2e/auth.test.ts b/tests/e2e/auth.test.ts deleted file mode 100644 index 4706bd1..0000000 --- a/tests/e2e/auth.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { generateId } from 'ai'; -import { getUnixTime } from 'date-fns'; -import { test, expect, type Page } from '@playwright/test'; - -test.use({ storageState: { cookies: [], origins: [] } }); - -const testEmail = `test-${getUnixTime(new Date())}@playwright.com`; -const testPassword = generateId(16); - -class AuthPage { - constructor(private page: Page) {} - - async gotoLogin() { - await this.page.goto('/login'); - await expect(this.page.getByRole('heading')).toContainText('Sign In'); - } - - async gotoRegister() { - await this.page.goto('/register'); - await expect(this.page.getByRole('heading')).toContainText('Sign Up'); - } - - async register(email: string, password: string) { - await this.gotoRegister(); - await this.page.getByPlaceholder('user@acme.com').click(); - await this.page.getByPlaceholder('user@acme.com').fill(email); - await this.page.getByLabel('Password').click(); - await this.page.getByLabel('Password').fill(password); - await this.page.getByRole('button', { name: 'Sign Up' }).click(); - } - - async login(email: string, password: string) { - await this.gotoLogin(); - await this.page.getByPlaceholder('user@acme.com').click(); - await this.page.getByPlaceholder('user@acme.com').fill(email); - await this.page.getByLabel('Password').click(); - await this.page.getByLabel('Password').fill(password); - await this.page.getByRole('button', { name: 'Sign In' }).click(); - } - - async expectToastToContain(text: string) { - await expect(this.page.getByTestId('toast')).toContainText(text); - } -} - -test.describe - .serial('authentication', () => { - let authPage: AuthPage; - - test.beforeEach(async ({ page }) => { - authPage = new AuthPage(page); - }); - - test('redirect to login page when unauthenticated', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('heading')).toContainText('Sign In'); - }); - - test('register a test account', async ({ page }) => { - await authPage.register(testEmail, testPassword); - await expect(page).toHaveURL('/'); - await authPage.expectToastToContain('Account created successfully!'); - }); - - test('register test account with existing email', async () => { - await authPage.register(testEmail, testPassword); - await authPage.expectToastToContain('Account already exists!'); - }); - - test('log into account', async ({ page }) => { - await authPage.login(testEmail, testPassword); - - await page.waitForURL('/'); - await expect(page).toHaveURL('/'); - await expect(page.getByPlaceholder('Send a message...')).toBeVisible(); - }); - }); diff --git a/tests/e2e/chat.test.ts b/tests/e2e/chat.test.ts index a41643a..a2e4ed4 100644 --- a/tests/e2e/chat.test.ts +++ b/tests/e2e/chat.test.ts @@ -1,7 +1,7 @@ import { ChatPage } from '../pages/chat'; -import { test, expect } from '@playwright/test'; +import { test, expect } from '../fixtures'; -test.describe('chat activity', () => { +test.describe('Chat activity', () => { let chatPage: ChatPage; test.beforeEach(async ({ page }) => { @@ -9,7 +9,7 @@ test.describe('chat activity', () => { await chatPage.createNewChat(); }); - test('send a user message and receive response', async () => { + test('Send a user message and receive response', async () => { await chatPage.sendUserMessage('Why is grass green?'); await chatPage.isGenerationComplete(); @@ -17,7 +17,7 @@ test.describe('chat activity', () => { expect(assistantMessage.content).toContain("It's just green duh!"); }); - test('redirect to /chat/:id after submitting message', async () => { + test('Redirect to /chat/:id after submitting message', async () => { await chatPage.sendUserMessage('Why is grass green?'); await chatPage.isGenerationComplete(); @@ -26,7 +26,7 @@ test.describe('chat activity', () => { await chatPage.hasChatIdInUrl(); }); - test('send a user message from suggestion', async () => { + test('Send a user message from suggestion', async () => { await chatPage.sendUserMessageFromSuggestion(); await chatPage.isGenerationComplete(); @@ -36,7 +36,7 @@ test.describe('chat activity', () => { ); }); - test('toggle between send/stop button based on activity', async () => { + test('Toggle between send/stop button based on activity', async () => { await expect(chatPage.sendButton).toBeVisible(); await expect(chatPage.sendButton).toBeDisabled(); @@ -51,14 +51,14 @@ test.describe('chat activity', () => { await expect(chatPage.sendButton).toBeVisible(); }); - test('stop generation during submission', async () => { + test('Stop generation during submission', async () => { await chatPage.sendUserMessage('Why is grass green?'); await expect(chatPage.stopButton).toBeVisible(); await chatPage.stopButton.click(); await expect(chatPage.sendButton).toBeVisible(); }); - test('edit user message and resubmit', async () => { + test('Edit user message and resubmit', async () => { await chatPage.sendUserMessage('Why is grass green?'); await chatPage.isGenerationComplete(); @@ -74,13 +74,13 @@ test.describe('chat activity', () => { expect(updatedAssistantMessage.content).toContain("It's just blue duh!"); }); - test('hide suggested actions after sending message', async () => { + test('Hide suggested actions after sending message', async () => { await chatPage.isElementVisible('suggested-actions'); await chatPage.sendUserMessageFromSuggestion(); await chatPage.isElementNotVisible('suggested-actions'); }); - test('upload file and send image attachment with message', async () => { + test('Upload file and send image attachment with message', async () => { await chatPage.addImageAttachment(); await chatPage.isElementVisible('attachments-preview'); @@ -98,7 +98,7 @@ test.describe('chat activity', () => { expect(assistantMessage.content).toBe('This painting is by Monet!'); }); - test('call weather tool', async () => { + test('Call weather tool', async () => { await chatPage.sendUserMessage("What's the weather in sf?"); await chatPage.isGenerationComplete(); @@ -109,7 +109,7 @@ test.describe('chat activity', () => { ); }); - test('upvote message', async () => { + test('Upvote message', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); @@ -118,7 +118,7 @@ test.describe('chat activity', () => { await chatPage.isVoteComplete(); }); - test('downvote message', async () => { + test('Downvote message', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); @@ -127,7 +127,7 @@ test.describe('chat activity', () => { await chatPage.isVoteComplete(); }); - test('update vote', async () => { + test('Update vote', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); diff --git a/tests/e2e/reasoning.setup.ts b/tests/e2e/reasoning.setup.ts deleted file mode 100644 index 80b6e92..0000000 --- a/tests/e2e/reasoning.setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from 'node:path'; -import { expect, test as setup } from '@playwright/test'; -import { ChatPage } from '../pages/chat'; - -const reasoningFile = path.join( - __dirname, - '../../playwright/.reasoning/session.json', -); - -setup('switch to reasoning model', async ({ page }) => { - const chatPage = new ChatPage(page); - await chatPage.createNewChat(); - - await chatPage.chooseModelFromSelector('chat-model-reasoning'); - - await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model'); - - await page.waitForTimeout(1000); - await page.context().storageState({ path: reasoningFile }); -}); diff --git a/tests/e2e/reasoning.test.ts b/tests/e2e/reasoning.test.ts index 9a1c8b3..1f0ce24 100644 --- a/tests/e2e/reasoning.test.ts +++ b/tests/e2e/reasoning.test.ts @@ -1,15 +1,15 @@ import { ChatPage } from '../pages/chat'; -import { test, expect } from '@playwright/test'; +import { test, expect } from '../fixtures'; test.describe('chat activity with reasoning', () => { let chatPage: ChatPage; - test.beforeEach(async ({ page }) => { - chatPage = new ChatPage(page); + test.beforeEach(async ({ curieContext }) => { + chatPage = new ChatPage(curieContext.page); await chatPage.createNewChat(); }); - test('send user message and generate response with reasoning', async () => { + test('Curie can send message and generate response with reasoning', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); @@ -21,7 +21,7 @@ test.describe('chat activity with reasoning', () => { ); }); - test('toggle reasoning visibility', async () => { + test('Curie can toggle reasoning visibility', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); @@ -37,7 +37,7 @@ test.describe('chat activity with reasoning', () => { await expect(reasoningElement).toBeVisible(); }); - test('edit message and resubmit', async () => { + test('Curie can edit message and resubmit', async () => { await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.isGenerationComplete(); diff --git a/tests/e2e/session.test.ts b/tests/e2e/session.test.ts new file mode 100644 index 0000000..2e1b652 --- /dev/null +++ b/tests/e2e/session.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from '../fixtures'; +import { AuthPage } from '../pages/auth'; +import { generateRandomTestUser } from '../helpers'; +import { ChatPage } from '../pages/chat'; + +test.describe + .serial('Guest Session', () => { + test('Authenticate as guest user when a new session is loaded', async ({ + page, + }) => { + const response = await page.goto('/'); + + if (!response) { + throw new Error('Failed to load page'); + } + + let request = response.request(); + + const chain = []; + + while (request) { + chain.unshift(request.url()); + request = request.redirectedFrom(); + } + + expect(chain).toEqual([ + 'http://localhost:3000/', + 'http://localhost:3000/api/auth/guest?redirectUrl=http%3A%2F%2Flocalhost%3A3000%2F', + 'http://localhost:3000/', + ]); + }); + + test('Log out is not available for guest users', async ({ page }) => { + await page.goto('/'); + + const sidebarToggleButton = page.getByTestId('sidebar-toggle-button'); + await sidebarToggleButton.click(); + + const userNavButton = page.getByTestId('user-nav-button'); + await expect(userNavButton).toBeVisible(); + + await userNavButton.click(); + const userNavMenu = page.getByTestId('user-nav-menu'); + await expect(userNavMenu).toBeVisible(); + + const authMenuItem = page.getByTestId('user-nav-item-auth'); + await expect(authMenuItem).toContainText('Login to your account'); + }); + + test('Do not authenticate as guest user when an existing non-guest session is active', async ({ + adaContext, + }) => { + const response = await adaContext.page.goto('/'); + + if (!response) { + throw new Error('Failed to load page'); + } + + let request = response.request(); + + const chain = []; + + while (request) { + chain.unshift(request.url()); + request = request.redirectedFrom(); + } + + expect(chain).toEqual(['http://localhost:3000/']); + }); + + test('Allow navigating to /login as guest user', async ({ page }) => { + await page.goto('/login'); + await page.waitForURL('/login'); + await expect(page).toHaveURL('/login'); + }); + + test('Allow navigating to /register as guest user', async ({ page }) => { + await page.goto('/register'); + await page.waitForURL('/register'); + await expect(page).toHaveURL('/register'); + }); + + test('Do not show email in user menu for guest user', async ({ page }) => { + await page.goto('/'); + + const sidebarToggleButton = page.getByTestId('sidebar-toggle-button'); + await sidebarToggleButton.click(); + + const userEmail = page.getByTestId('user-email'); + await expect(userEmail).toContainText('Guest'); + }); + }); + +test.describe + .serial('Login and Registration', () => { + let authPage: AuthPage; + + const testUser = generateRandomTestUser(); + + test.beforeEach(async ({ page }) => { + authPage = new AuthPage(page); + }); + + test('Register new account', async () => { + await authPage.register(testUser.email, testUser.password); + await authPage.expectToastToContain('Account created successfully!'); + }); + + test('Register new account with existing email', async () => { + await authPage.register(testUser.email, testUser.password); + await authPage.expectToastToContain('Account already exists!'); + }); + + test('Log into account that exists', async ({ page }) => { + await authPage.login(testUser.email, testUser.password); + + await page.waitForURL('/'); + await expect(page.getByPlaceholder('Send a message...')).toBeVisible(); + }); + + test('Display user email in user menu', async ({ page }) => { + await authPage.login(testUser.email, testUser.password); + + await page.waitForURL('/'); + await expect(page.getByPlaceholder('Send a message...')).toBeVisible(); + + const userEmail = await page.getByTestId('user-email'); + await expect(userEmail).toHaveText(testUser.email); + }); + + test('Log out as non-guest user', async () => { + await authPage.logout(testUser.email, testUser.password); + }); + + test('Do not force create a guest session if non-guest session already exists', async ({ + page, + }) => { + await authPage.login(testUser.email, testUser.password); + await page.waitForURL('/'); + + const userEmail = await page.getByTestId('user-email'); + await expect(userEmail).toHaveText(testUser.email); + + await page.goto('/api/auth/guest'); + await page.waitForURL('/'); + + const updatedUserEmail = await page.getByTestId('user-email'); + await expect(updatedUserEmail).toHaveText(testUser.email); + }); + + test('Log out is available for non-guest users', async ({ page }) => { + await authPage.login(testUser.email, testUser.password); + await page.waitForURL('/'); + + authPage.openSidebar(); + + const userNavButton = page.getByTestId('user-nav-button'); + await expect(userNavButton).toBeVisible(); + + await userNavButton.click(); + const userNavMenu = page.getByTestId('user-nav-menu'); + await expect(userNavMenu).toBeVisible(); + + const authMenuItem = page.getByTestId('user-nav-item-auth'); + await expect(authMenuItem).toContainText('Sign out'); + }); + + test('Do not navigate to /register for non-guest users', async ({ + page, + }) => { + await authPage.login(testUser.email, testUser.password); + await page.waitForURL('/'); + + await page.goto('/register'); + await expect(page).toHaveURL('/'); + }); + + test('Do not navigate to /login for non-guest users', async ({ page }) => { + await authPage.login(testUser.email, testUser.password); + await page.waitForURL('/'); + + await page.goto('/login'); + await expect(page).toHaveURL('/'); + }); + }); + +test.describe('Entitlements', () => { + let chatPage: ChatPage; + + test.beforeEach(async ({ page }) => { + chatPage = new ChatPage(page); + }); + + test('Guest user cannot send more than 20 messages/day', async () => { + await chatPage.createNewChat(); + + for (let i = 0; i <= 20; i++) { + await chatPage.sendUserMessage('Why is the sky blue?'); + await chatPage.isGenerationComplete(); + } + + await chatPage.sendUserMessage('Why is the sky blue?'); + await chatPage.expectToastToContain( + 'You have exceeded your maximum number of messages for the day! Please try again later.', + ); + }); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 07eea52..52b10dc 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -1,34 +1,51 @@ import { expect as baseExpect, test as baseTest } from '@playwright/test'; -import { createAuthenticatedContext, type UserContext } from './auth-helper'; +import { createAuthenticatedContext, type UserContext } from './helpers'; +import { getUnixTime } from 'date-fns'; interface Fixtures { adaContext: UserContext; babbageContext: UserContext; + curieContext: UserContext; } export const test = baseTest.extend({ adaContext: [ - async ({ browser }, use) => { + async ({ browser }, use, workerInfo) => { const ada = await createAuthenticatedContext({ browser, - name: 'ada', + name: `ada-${workerInfo.workerIndex}-${getUnixTime(new Date())}`, }); + await use(ada); await ada.context.close(); }, { scope: 'worker' }, ], babbageContext: [ - async ({ browser }, use) => { + async ({ browser }, use, workerInfo) => { const babbage = await createAuthenticatedContext({ browser, - name: 'babbage', + name: `babbage-${workerInfo.workerIndex}-${getUnixTime(new Date())}`, }); + await use(babbage); await babbage.context.close(); }, { scope: 'worker' }, ], + curieContext: [ + async ({ browser }, use, workerInfo) => { + const curie = await createAuthenticatedContext({ + browser, + name: `curie-${workerInfo.workerIndex}-${getUnixTime(new Date())}`, + chatModel: 'chat-model-reasoning', + }); + + await use(curie); + await curie.context.close(); + }, + { scope: 'worker' }, + ], }); export const expect = baseExpect; diff --git a/tests/auth-helper.ts b/tests/helpers.ts similarity index 62% rename from tests/auth-helper.ts rename to tests/helpers.ts index 7b0882a..701f455 100644 --- a/tests/auth-helper.ts +++ b/tests/helpers.ts @@ -8,6 +8,7 @@ import { type Page, } from '@playwright/test'; import { generateId } from 'ai'; +import { ChatPage } from './pages/chat'; import { getUnixTime } from 'date-fns'; export type UserContext = { @@ -19,22 +20,24 @@ export type UserContext = { export async function createAuthenticatedContext({ browser, name, + chatModel = 'chat-model', }: { browser: Browser; name: string; + chatModel?: 'chat-model' | 'chat-model-reasoning'; }): Promise { - const authDir = path.join(__dirname, '../playwright/.auth'); + const directory = path.join(__dirname, '../playwright/.sessions'); - if (!fs.existsSync(authDir)) { - fs.mkdirSync(authDir, { recursive: true }); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); } - const storageFile = path.join(authDir, `${name}.json`); + const storageFile = path.join(directory, `${name}.json`); const context = await browser.newContext(); const page = await context.newPage(); - const email = `test-${name}-${getUnixTime(new Date())}@playwright.com`; + const email = `test-${name}@playwright.com`; const password = generateId(16); await page.goto('http://localhost:3000/register'); @@ -48,6 +51,12 @@ export async function createAuthenticatedContext({ 'Account created successfully!', ); + const chatPage = new ChatPage(page); + await chatPage.createNewChat(); + await chatPage.chooseModelFromSelector('chat-model-reasoning'); + await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model'); + + await page.waitForTimeout(1000); await context.storageState({ path: storageFile }); await page.close(); @@ -60,3 +69,13 @@ export async function createAuthenticatedContext({ request: newContext.request, }; } + +export function generateRandomTestUser() { + const email = `test-${getUnixTime(new Date())}@playwright.com`; + const password = generateId(16); + + return { + email, + password, + }; +} diff --git a/tests/pages/auth.ts b/tests/pages/auth.ts new file mode 100644 index 0000000..47970ec --- /dev/null +++ b/tests/pages/auth.ts @@ -0,0 +1,65 @@ +import type { Page } from '@playwright/test'; +import { expect } from '../fixtures'; + +export class AuthPage { + constructor(private page: Page) {} + + async gotoLogin() { + await this.page.goto('/login'); + await expect(this.page.getByRole('heading')).toContainText('Sign In'); + } + + async gotoRegister() { + await this.page.goto('/register'); + await expect(this.page.getByRole('heading')).toContainText('Sign Up'); + } + + async register(email: string, password: string) { + await this.gotoRegister(); + await this.page.getByPlaceholder('user@acme.com').click(); + await this.page.getByPlaceholder('user@acme.com').fill(email); + await this.page.getByLabel('Password').click(); + await this.page.getByLabel('Password').fill(password); + await this.page.getByRole('button', { name: 'Sign Up' }).click(); + } + + async login(email: string, password: string) { + await this.gotoLogin(); + await this.page.getByPlaceholder('user@acme.com').click(); + await this.page.getByPlaceholder('user@acme.com').fill(email); + await this.page.getByLabel('Password').click(); + await this.page.getByLabel('Password').fill(password); + await this.page.getByRole('button', { name: 'Sign In' }).click(); + } + + async logout(email: string, password: string) { + await this.login(email, password); + await this.page.waitForURL('/'); + + await this.openSidebar(); + + const userNavButton = this.page.getByTestId('user-nav-button'); + await expect(userNavButton).toBeVisible(); + + await userNavButton.click(); + const userNavMenu = this.page.getByTestId('user-nav-menu'); + await expect(userNavMenu).toBeVisible(); + + const authMenuItem = this.page.getByTestId('user-nav-item-auth'); + await expect(authMenuItem).toContainText('Sign out'); + + await authMenuItem.click(); + + const userEmail = this.page.getByTestId('user-email'); + await expect(userEmail).toContainText('Guest'); + } + + async expectToastToContain(text: string) { + await expect(this.page.getByTestId('toast')).toContainText(text); + } + + async openSidebar() { + const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button'); + await sidebarToggleButton.click(); + } +} diff --git a/tests/pages/chat.ts b/tests/pages/chat.ts index 61a43b8..2b770b9 100644 --- a/tests/pages/chat.ts +++ b/tests/pages/chat.ts @@ -1,7 +1,7 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import { chatModels } from '@/lib/ai/models'; -import { expect, Page } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; export class ChatPage { constructor(private page: Page) {} @@ -179,4 +179,13 @@ export class ChatPage { }, }; } + + async expectToastToContain(text: string) { + await expect(this.page.getByTestId('toast')).toContainText(text); + } + + async openSideBar() { + const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button'); + await sidebarToggleButton.click(); + } } diff --git a/tests/routes/chat.test.ts b/tests/routes/chat.test.ts index f55c42f..2766939 100644 --- a/tests/routes/chat.test.ts +++ b/tests/routes/chat.test.ts @@ -21,7 +21,7 @@ test.describe test('Ada can invoke chat generation', async ({ adaContext }) => { const chatId = generateUUID(); - const response = await adaContext.request.post('api/chat', { + const response = await adaContext.request.post('/api/chat', { data: { id: chatId, messages: TEST_PROMPTS.SKY.MESSAGES, @@ -44,7 +44,7 @@ test.describe }) => { const [chatId] = chatIdsCreatedByAda; - const response = await babbageContext.request.post('api/chat', { + const response = await babbageContext.request.post('/api/chat', { data: { id: chatId, messages: TEST_PROMPTS.GRASS.MESSAGES, @@ -61,7 +61,7 @@ test.describe const [chatId] = chatIdsCreatedByAda; const response = await babbageContext.request.delete( - `api/chat?id=${chatId}`, + `/api/chat?id=${chatId}`, ); expect(response.status()).toBe(403); @@ -72,7 +72,9 @@ test.describe test('Ada can delete her own chat', async ({ adaContext }) => { const [chatId] = chatIdsCreatedByAda; - const response = await adaContext.request.delete(`api/chat?id=${chatId}`); + const response = await adaContext.request.delete( + `/api/chat?id=${chatId}`, + ); expect(response.status()).toBe(200); const deletedChat = await response.json();