feat: support guest session (#919)
This commit is contained in:
parent
24cb2ce19b
commit
9279135355
34 changed files with 741 additions and 288 deletions
21
app/(auth)/api/auth/guest/route.ts
Normal file
21
app/(auth)/api/auth/guest/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -9,31 +9,5 @@ export const authConfig = {
|
||||||
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
|
// 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
|
// while this file is also used in non-Node.js environments
|
||||||
],
|
],
|
||||||
callbacks: {
|
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies NextAuthConfig;
|
} satisfies NextAuthConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
import { compare } from 'bcrypt-ts';
|
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 Credentials from 'next-auth/providers/credentials';
|
||||||
|
import { createGuestUser, getUser } from '@/lib/db/queries';
|
||||||
import { getUser } from '@/lib/db/queries';
|
|
||||||
|
|
||||||
import { authConfig } from './auth.config';
|
import { authConfig } from './auth.config';
|
||||||
import { DUMMY_PASSWORD } from '@/lib/constants';
|
import { DUMMY_PASSWORD } from '@/lib/constants';
|
||||||
|
import type { DefaultJWT } from 'next-auth/jwt';
|
||||||
|
|
||||||
interface ExtendedSession extends Session {
|
export type UserType = 'guest' | 'regular';
|
||||||
user: User;
|
|
||||||
|
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 {
|
export const {
|
||||||
|
|
@ -40,27 +59,31 @@ export const {
|
||||||
|
|
||||||
if (!passwordsMatch) return null;
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id;
|
token.id = user.id as string;
|
||||||
|
token.type = user.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({
|
async session({ session, token }) {
|
||||||
session,
|
|
||||||
token,
|
|
||||||
}: {
|
|
||||||
session: ExtendedSession;
|
|
||||||
token: any;
|
|
||||||
}) {
|
|
||||||
if (session.user) {
|
if (session.user) {
|
||||||
session.user.id = token.id as string;
|
session.user.id = token.id;
|
||||||
|
session.user.type = token.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { AuthForm } from '@/components/auth-form';
|
||||||
import { SubmitButton } from '@/components/submit-button';
|
import { SubmitButton } from '@/components/submit-button';
|
||||||
|
|
||||||
import { login, type LoginActionState } from '../actions';
|
import { login, type LoginActionState } from '../actions';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -23,6 +24,8 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { update: updateSession } = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status === 'failed') {
|
if (state.status === 'failed') {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -36,6 +39,7 @@ export default function Page() {
|
||||||
});
|
});
|
||||||
} else if (state.status === 'success') {
|
} else if (state.status === 'success') {
|
||||||
setIsSuccessful(true);
|
setIsSuccessful(true);
|
||||||
|
updateSession();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
}, [state.status]);
|
}, [state.status]);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { SubmitButton } from '@/components/submit-button';
|
||||||
|
|
||||||
import { register, type RegisterActionState } from '../actions';
|
import { register, type RegisterActionState } from '../actions';
|
||||||
import { toast } from '@/components/toast';
|
import { toast } from '@/components/toast';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -23,6 +24,8 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { update: updateSession } = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status === 'user_exists') {
|
if (state.status === 'user_exists') {
|
||||||
toast({ type: 'error', description: 'Account already exists!' });
|
toast({ type: 'error', description: 'Account already exists!' });
|
||||||
|
|
@ -37,6 +40,7 @@ export default function Page() {
|
||||||
toast({ type: 'success', description: 'Account created successfully!' });
|
toast({ type: 'success', description: 'Account created successfully!' });
|
||||||
|
|
||||||
setIsSuccessful(true);
|
setIsSuccessful(true);
|
||||||
|
updateSession();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import {
|
||||||
smoothStream,
|
smoothStream,
|
||||||
streamText,
|
streamText,
|
||||||
} from 'ai';
|
} from 'ai';
|
||||||
import { auth } from '@/app/(auth)/auth';
|
import { auth, type UserType } from '@/app/(auth)/auth';
|
||||||
import { systemPrompt } from '@/lib/ai/prompts';
|
import { systemPrompt } from '@/lib/ai/prompts';
|
||||||
import {
|
import {
|
||||||
deleteChatById,
|
deleteChatById,
|
||||||
getChatById,
|
getChatById,
|
||||||
|
getMessageCountByUserId,
|
||||||
saveChat,
|
saveChat,
|
||||||
saveMessages,
|
saveMessages,
|
||||||
} from '@/lib/db/queries';
|
} 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 { getWeather } from '@/lib/ai/tools/get-weather';
|
||||||
import { isProductionEnvironment } from '@/lib/constants';
|
import { isProductionEnvironment } from '@/lib/constants';
|
||||||
import { myProvider } from '@/lib/ai/providers';
|
import { myProvider } from '@/lib/ai/providers';
|
||||||
|
import { entitlementsByUserType } from '@/lib/ai/entitlements';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
|
@ -46,6 +48,22 @@ export async function POST(request: Request) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
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);
|
const userMessage = getMostRecentUserMessage(messages);
|
||||||
|
|
||||||
if (!userMessage) {
|
if (!userMessage) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { auth } from '@/app/(auth)/auth';
|
import { auth } from '@/app/(auth)/auth';
|
||||||
import { Chat } from '@/components/chat';
|
import { Chat } from '@/components/chat';
|
||||||
import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
|
import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
|
||||||
import { DataStreamHandler } from '@/components/data-stream-handler';
|
import { DataStreamHandler } from '@/components/data-stream-handler';
|
||||||
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
||||||
import { DBMessage } from '@/lib/db/schema';
|
import type { DBMessage } from '@/lib/db/schema';
|
||||||
import { Attachment, UIMessage } from 'ai';
|
import type { Attachment, UIMessage } from 'ai';
|
||||||
|
|
||||||
export default async function Page(props: { params: Promise<{ id: string }> }) {
|
export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
@ -20,8 +20,12 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/api/auth/guest');
|
||||||
|
}
|
||||||
|
|
||||||
if (chat.visibility === 'private') {
|
if (chat.visibility === 'private') {
|
||||||
if (!session || !session.user) {
|
if (!session.user) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +63,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
selectedChatModel={DEFAULT_CHAT_MODEL}
|
selectedChatModel={DEFAULT_CHAT_MODEL}
|
||||||
selectedVisibilityType={chat.visibility}
|
selectedVisibilityType={chat.visibility}
|
||||||
isReadonly={session?.user?.id !== chat.userId}
|
isReadonly={session?.user?.id !== chat.userId}
|
||||||
|
session={session}
|
||||||
/>
|
/>
|
||||||
<DataStreamHandler id={id} />
|
<DataStreamHandler id={id} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -73,6 +78,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
selectedChatModel={chatModelFromCookie.value}
|
selectedChatModel={chatModelFromCookie.value}
|
||||||
selectedVisibilityType={chat.visibility}
|
selectedVisibilityType={chat.visibility}
|
||||||
isReadonly={session?.user?.id !== chat.userId}
|
isReadonly={session?.user?.id !== chat.userId}
|
||||||
|
session={session}
|
||||||
/>
|
/>
|
||||||
<DataStreamHandler id={id} />
|
<DataStreamHandler id={id} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,16 @@ import { Chat } from '@/components/chat';
|
||||||
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { DataStreamHandler } from '@/components/data-stream-handler';
|
import { DataStreamHandler } from '@/components/data-stream-handler';
|
||||||
|
import { auth } from '../(auth)/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/api/auth/guest');
|
||||||
|
}
|
||||||
|
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
@ -21,6 +29,7 @@ export default async function Page() {
|
||||||
selectedChatModel={DEFAULT_CHAT_MODEL}
|
selectedChatModel={DEFAULT_CHAT_MODEL}
|
||||||
selectedVisibilityType="private"
|
selectedVisibilityType="private"
|
||||||
isReadonly={false}
|
isReadonly={false}
|
||||||
|
session={session}
|
||||||
/>
|
/>
|
||||||
<DataStreamHandler id={id} />
|
<DataStreamHandler id={id} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -36,6 +45,7 @@ export default async function Page() {
|
||||||
selectedChatModel={modelIdFromCookie.value}
|
selectedChatModel={modelIdFromCookie.value}
|
||||||
selectedVisibilityType="private"
|
selectedVisibilityType="private"
|
||||||
isReadonly={false}
|
isReadonly={false}
|
||||||
|
session={session}
|
||||||
/>
|
/>
|
||||||
<DataStreamHandler id={id} />
|
<DataStreamHandler id={id} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
import { ThemeProvider } from '@/components/theme-provider';
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL('https://chat.vercel.ai'),
|
metadataBase: new URL('https://chat.vercel.ai'),
|
||||||
|
|
@ -77,7 +78,7 @@ export default async function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
{children}
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,20 @@ import { useSidebar } from './ui/sidebar';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { type VisibilityType, VisibilitySelector } from './visibility-selector';
|
import { type VisibilityType, VisibilitySelector } from './visibility-selector';
|
||||||
|
import type { Session } from 'next-auth';
|
||||||
|
|
||||||
function PureChatHeader({
|
function PureChatHeader({
|
||||||
chatId,
|
chatId,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
selectedVisibilityType,
|
selectedVisibilityType,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
|
session,
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
selectedVisibilityType: VisibilityType;
|
selectedVisibilityType: VisibilityType;
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
|
session: Session;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { open } = useSidebar();
|
const { open } = useSidebar();
|
||||||
|
|
@ -54,6 +57,7 @@ function PureChatHeader({
|
||||||
|
|
||||||
{!isReadonly && (
|
{!isReadonly && (
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
session={session}
|
||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
className="order-1 md:order-2"
|
className="order-1 md:order-2"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import { MultimodalInput } from './multimodal-input';
|
||||||
import { Messages } from './messages';
|
import { Messages } from './messages';
|
||||||
import type { VisibilityType } from './visibility-selector';
|
import type { VisibilityType } from './visibility-selector';
|
||||||
import { useArtifactSelector } from '@/hooks/use-artifact';
|
import { useArtifactSelector } from '@/hooks/use-artifact';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { unstable_serialize } from 'swr/infinite';
|
import { unstable_serialize } from 'swr/infinite';
|
||||||
import { getChatHistoryPaginationKey } from './sidebar-history';
|
import { getChatHistoryPaginationKey } from './sidebar-history';
|
||||||
|
import { toast } from './toast';
|
||||||
|
import type { Session } from 'next-auth';
|
||||||
|
|
||||||
export function Chat({
|
export function Chat({
|
||||||
id,
|
id,
|
||||||
|
|
@ -22,12 +23,14 @@ export function Chat({
|
||||||
selectedChatModel,
|
selectedChatModel,
|
||||||
selectedVisibilityType,
|
selectedVisibilityType,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
|
session,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
initialMessages: Array<UIMessage>;
|
initialMessages: Array<UIMessage>;
|
||||||
selectedChatModel: string;
|
selectedChatModel: string;
|
||||||
selectedVisibilityType: VisibilityType;
|
selectedVisibilityType: VisibilityType;
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
|
session: Session;
|
||||||
}) {
|
}) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
|
|
@ -51,8 +54,11 @@ export function Chat({
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
toast.error('An error occurred, please try again!');
|
toast({
|
||||||
|
type: 'error',
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -72,6 +78,7 @@ export function Chat({
|
||||||
selectedModelId={selectedChatModel}
|
selectedModelId={selectedChatModel}
|
||||||
selectedVisibilityType={selectedVisibilityType}
|
selectedVisibilityType={selectedVisibilityType}
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
|
session={session}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Messages
|
<Messages
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,34 @@ import { chatModels } from '@/lib/ai/models';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
|
import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
|
||||||
|
import { entitlementsByUserType } from '@/lib/ai/entitlements';
|
||||||
|
import type { Session } from 'next-auth';
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
|
session,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
|
session: Session;
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
} & React.ComponentProps<typeof Button>) {
|
} & React.ComponentProps<typeof Button>) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [optimisticModelId, setOptimisticModelId] =
|
const [optimisticModelId, setOptimisticModelId] =
|
||||||
useOptimistic(selectedModelId);
|
useOptimistic(selectedModelId);
|
||||||
|
|
||||||
|
const userType = session.user.type;
|
||||||
|
const { availableChatModelIds } = entitlementsByUserType[userType];
|
||||||
|
|
||||||
|
const availableChatModels = chatModels.filter((chatModel) =>
|
||||||
|
availableChatModelIds.includes(chatModel.id),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedChatModel = useMemo(
|
const selectedChatModel = useMemo(
|
||||||
() => chatModels.find((chatModel) => chatModel.id === optimisticModelId),
|
() =>
|
||||||
[optimisticModelId],
|
availableChatModels.find(
|
||||||
|
(chatModel) => chatModel.id === optimisticModelId,
|
||||||
|
),
|
||||||
|
[optimisticModelId, availableChatModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -49,7 +63,7 @@ export function ModelSelector({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-[300px]">
|
<DropdownMenuContent align="start" className="min-w-[300px]">
|
||||||
{chatModels.map((chatModel) => {
|
{availableChatModels.map((chatModel) => {
|
||||||
const { id } = chatModel;
|
const { id } = chatModel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function SidebarToggle({
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="sidebar-toggle-button"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="md:px-2 md:h-fit"
|
className="md:px-2 md:h-fit"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronUp } from 'lucide-react';
|
import { ChevronUp } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { User } from 'next-auth';
|
import type { User } from 'next-auth';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut, useSession } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,49 +18,92 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from './toast';
|
||||||
|
import { LoaderIcon } from './icons';
|
||||||
|
import { guestRegex } from '@/lib/constants';
|
||||||
|
|
||||||
export function SidebarUserNav({ user }: { user: User }) {
|
export function SidebarUserNav({ user }: { user: User }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data, status } = useSession();
|
||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
|
const isGuest = guestRegex.test(data?.user?.email ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
|
{status === 'loading' ? (
|
||||||
<Image
|
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10 justify-between">
|
||||||
src={`https://avatar.vercel.sh/${user.email}`}
|
<div className="flex flex-row gap-2">
|
||||||
alt={user.email ?? 'User Avatar'}
|
<div className="size-6 bg-zinc-500/30 rounded-full animate-pulse" />
|
||||||
width={24}
|
<span className="bg-zinc-500/30 text-transparent rounded-md animate-pulse">
|
||||||
height={24}
|
Loading auth status
|
||||||
className="rounded-full"
|
</span>
|
||||||
/>
|
</div>
|
||||||
<span className="truncate">{user?.email}</span>
|
<div className="animate-spin text-zinc-500">
|
||||||
<ChevronUp className="ml-auto" />
|
<LoaderIcon />
|
||||||
</SidebarMenuButton>
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuButton
|
||||||
|
data-testid="user-nav-button"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`https://avatar.vercel.sh/${user.email}`}
|
||||||
|
alt={user.email ?? 'User Avatar'}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<span data-testid="user-email" className="truncate">
|
||||||
|
{isGuest ? 'Guest' : user?.email}
|
||||||
|
</span>
|
||||||
|
<ChevronUp className="ml-auto" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
data-testid="user-nav-menu"
|
||||||
side="top"
|
side="top"
|
||||||
className="w-[--radix-popper-anchor-width]"
|
className="w-[--radix-popper-anchor-width]"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
data-testid="user-nav-item-theme"
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
>
|
>
|
||||||
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
|
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
signOut({
|
if (status === 'loading') {
|
||||||
redirectTo: '/',
|
toast({
|
||||||
});
|
type: 'error',
|
||||||
|
description:
|
||||||
|
'Checking authentication status, please try again!',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGuest) {
|
||||||
|
router.push('/login');
|
||||||
|
} else {
|
||||||
|
signOut({
|
||||||
|
redirectTo: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out
|
{isGuest ? 'Login to your account' : 'Sign out'}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { ReactNode } from 'react';
|
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { toast as sonnerToast } from 'sonner';
|
import { toast as sonnerToast } from 'sonner';
|
||||||
import { CheckCircleFillIcon, WarningIcon } from './icons';
|
import { CheckCircleFillIcon, WarningIcon } from './icons';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const iconsByType: Record<'success' | 'error', ReactNode> = {
|
const iconsByType: Record<'success' | 'error', ReactNode> = {
|
||||||
success: <CheckCircleFillIcon />,
|
success: <CheckCircleFillIcon />,
|
||||||
|
|
@ -18,20 +19,48 @@ export function toast(props: Omit<ToastProps, 'id'>) {
|
||||||
function Toast(props: ToastProps) {
|
function Toast(props: ToastProps) {
|
||||||
const { id, type, description } = props;
|
const { id, type, description } = props;
|
||||||
|
|
||||||
|
const descriptionRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<div className="flex w-full toast-mobile:w-[356px] justify-center">
|
<div className="flex w-full toast-mobile:w-[356px] justify-center">
|
||||||
<div
|
<div
|
||||||
data-testid="toast"
|
data-testid="toast"
|
||||||
key={id}
|
key={id}
|
||||||
className="bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-2 items-center"
|
className={cn(
|
||||||
|
'bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-3',
|
||||||
|
multiLine ? 'items-start' : 'items-center',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-type={type}
|
data-type={type}
|
||||||
className="data-[type=error]:text-red-600 data-[type=success]:text-green-600"
|
className={cn(
|
||||||
|
'data-[type=error]:text-red-600 data-[type=success]:text-green-600',
|
||||||
|
{ 'pt-1': multiLine },
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{iconsByType[type]}
|
{iconsByType[type]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-950 text-sm">{description}</div>
|
<div ref={descriptionRef} className="text-zinc-950 text-sm">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
29
lib/ai/entitlements.ts
Normal file
29
lib/ai/entitlements.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { UserType } from '@/app/(auth)/auth';
|
||||||
|
import type { ChatModel } from './models';
|
||||||
|
|
||||||
|
interface Entitlements {
|
||||||
|
maxMessagesPerDay: number;
|
||||||
|
availableChatModelIds: Array<ChatModel['id']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const entitlementsByUserType: Record<UserType, Entitlements> = {
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const DEFAULT_CHAT_MODEL: string = 'chat-model';
|
export const DEFAULT_CHAT_MODEL: string = 'chat-model';
|
||||||
|
|
||||||
interface ChatModel {
|
export interface ChatModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { generateDummyPassword } from './db/utils';
|
import { generateDummyPassword } from './db/utils';
|
||||||
|
|
||||||
export const isProductionEnvironment = process.env.NODE_ENV === 'production';
|
export const isProductionEnvironment = process.env.NODE_ENV === 'production';
|
||||||
|
export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development';
|
||||||
export const isTestEnvironment = Boolean(
|
export const isTestEnvironment = Boolean(
|
||||||
process.env.PLAYWRIGHT_TEST_BASE_URL ||
|
process.env.PLAYWRIGHT_TEST_BASE_URL ||
|
||||||
process.env.PLAYWRIGHT ||
|
process.env.PLAYWRIGHT ||
|
||||||
process.env.CI_PLAYWRIGHT,
|
process.env.CI_PLAYWRIGHT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const guestRegex = /^guest-\d+$/;
|
||||||
|
|
||||||
export const DUMMY_PASSWORD = generateDummyPassword();
|
export const DUMMY_PASSWORD = generateDummyPassword();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'server-only';
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
asc,
|
asc,
|
||||||
|
count,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gt,
|
gt,
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
type Chat,
|
type Chat,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import type { ArtifactKind } from '@/components/artifact';
|
import type { ArtifactKind } from '@/components/artifact';
|
||||||
|
import { generateUUID } from '../utils';
|
||||||
import { generateHashedPassword } from './utils';
|
import { generateHashedPassword } from './utils';
|
||||||
|
|
||||||
// Optionally, if not using email/pass login, you can
|
// 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({
|
export async function saveChat({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -422,3 +439,34 @@ export async function updateChatVisiblityById({
|
||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
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).*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ai-chatbot",
|
"name": "ai-chatbot",
|
||||||
"version": "3.0.6",
|
"version": "3.0.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ export default defineConfig({
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 1,
|
retries: 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* 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 to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|
@ -40,61 +40,27 @@ export default defineConfig({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* 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 */
|
/* Configure global timeout for each test */
|
||||||
timeout: 60 * 1000, // 30 seconds
|
timeout: 120 * 1000, // 120 seconds
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 60 * 1000,
|
timeout: 120 * 1000,
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects */
|
/* Configure projects */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'setup:auth',
|
name: 'e2e',
|
||||||
testMatch: /e2e\/auth.setup.ts/,
|
testMatch: /e2e\/.*.test.ts/,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'setup:reasoning',
|
|
||||||
testMatch: /e2e\/reasoning.setup.ts/,
|
|
||||||
dependencies: ['setup:auth'],
|
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...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',
|
name: 'routes',
|
||||||
testMatch: /routes\/.*.test.ts/,
|
testMatch: /routes\/.*.test.ts/,
|
||||||
dependencies: [],
|
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
},
|
},
|
||||||
|
|
@ -134,7 +100,7 @@ export default defineConfig({
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'pnpm dev',
|
command: 'pnpm dev',
|
||||||
url: baseURL,
|
url: `${baseURL}/ping`,
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '../fixtures';
|
||||||
import { ChatPage } from '../pages/chat';
|
import { ChatPage } from '../pages/chat';
|
||||||
import { ArtifactPage } from '../pages/artifact';
|
import { ArtifactPage } from '../pages/artifact';
|
||||||
|
|
||||||
test.describe('artifacts activity', () => {
|
test.describe('Artifacts activity', () => {
|
||||||
let chatPage: ChatPage;
|
let chatPage: ChatPage;
|
||||||
let artifactPage: ArtifactPage;
|
let artifactPage: ArtifactPage;
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ test.describe('artifacts activity', () => {
|
||||||
await chatPage.createNewChat();
|
await chatPage.createNewChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create a text artifact', async () => {
|
test('Create a text artifact', async () => {
|
||||||
await chatPage.createNewChat();
|
await chatPage.createNewChat();
|
||||||
|
|
||||||
await chatPage.sendUserMessage(
|
await chatPage.sendUserMessage(
|
||||||
|
|
@ -31,7 +31,7 @@ test.describe('artifacts activity', () => {
|
||||||
await chatPage.hasChatIdInUrl();
|
await chatPage.hasChatIdInUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggle artifact visibility', async () => {
|
test('Toggle artifact visibility', async () => {
|
||||||
await chatPage.createNewChat();
|
await chatPage.createNewChat();
|
||||||
|
|
||||||
await chatPage.sendUserMessage(
|
await chatPage.sendUserMessage(
|
||||||
|
|
@ -50,7 +50,7 @@ test.describe('artifacts activity', () => {
|
||||||
await chatPage.isElementNotVisible('artifact');
|
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.createNewChat();
|
||||||
|
|
||||||
await chatPage.sendUserMessage(
|
await chatPage.sendUserMessage(
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ChatPage } from '../pages/chat';
|
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;
|
let chatPage: ChatPage;
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|
@ -9,7 +9,7 @@ test.describe('chat activity', () => {
|
||||||
await chatPage.createNewChat();
|
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.sendUserMessage('Why is grass green?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ test.describe('chat activity', () => {
|
||||||
expect(assistantMessage.content).toContain("It's just green duh!");
|
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.sendUserMessage('Why is grass green?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ test.describe('chat activity', () => {
|
||||||
await chatPage.hasChatIdInUrl();
|
await chatPage.hasChatIdInUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('send a user message from suggestion', async () => {
|
test('Send a user message from suggestion', async () => {
|
||||||
await chatPage.sendUserMessageFromSuggestion();
|
await chatPage.sendUserMessageFromSuggestion();
|
||||||
await chatPage.isGenerationComplete();
|
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).toBeVisible();
|
||||||
await expect(chatPage.sendButton).toBeDisabled();
|
await expect(chatPage.sendButton).toBeDisabled();
|
||||||
|
|
||||||
|
|
@ -51,14 +51,14 @@ test.describe('chat activity', () => {
|
||||||
await expect(chatPage.sendButton).toBeVisible();
|
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 chatPage.sendUserMessage('Why is grass green?');
|
||||||
await expect(chatPage.stopButton).toBeVisible();
|
await expect(chatPage.stopButton).toBeVisible();
|
||||||
await chatPage.stopButton.click();
|
await chatPage.stopButton.click();
|
||||||
await expect(chatPage.sendButton).toBeVisible();
|
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.sendUserMessage('Why is grass green?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -74,13 +74,13 @@ test.describe('chat activity', () => {
|
||||||
expect(updatedAssistantMessage.content).toContain("It's just blue duh!");
|
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.isElementVisible('suggested-actions');
|
||||||
await chatPage.sendUserMessageFromSuggestion();
|
await chatPage.sendUserMessageFromSuggestion();
|
||||||
await chatPage.isElementNotVisible('suggested-actions');
|
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.addImageAttachment();
|
||||||
|
|
||||||
await chatPage.isElementVisible('attachments-preview');
|
await chatPage.isElementVisible('attachments-preview');
|
||||||
|
|
@ -98,7 +98,7 @@ test.describe('chat activity', () => {
|
||||||
expect(assistantMessage.content).toBe('This painting is by Monet!');
|
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.sendUserMessage("What's the weather in sf?");
|
||||||
await chatPage.isGenerationComplete();
|
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.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -118,7 +118,7 @@ test.describe('chat activity', () => {
|
||||||
await chatPage.isVoteComplete();
|
await chatPage.isVoteComplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('downvote message', async () => {
|
test('Downvote message', async () => {
|
||||||
await chatPage.sendUserMessage('Why is the sky blue?');
|
await chatPage.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -127,7 +127,7 @@ test.describe('chat activity', () => {
|
||||||
await chatPage.isVoteComplete();
|
await chatPage.isVoteComplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update vote', async () => {
|
test('Update vote', async () => {
|
||||||
await chatPage.sendUserMessage('Why is the sky blue?');
|
await chatPage.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { ChatPage } from '../pages/chat';
|
import { ChatPage } from '../pages/chat';
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '../fixtures';
|
||||||
|
|
||||||
test.describe('chat activity with reasoning', () => {
|
test.describe('chat activity with reasoning', () => {
|
||||||
let chatPage: ChatPage;
|
let chatPage: ChatPage;
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ curieContext }) => {
|
||||||
chatPage = new ChatPage(page);
|
chatPage = new ChatPage(curieContext.page);
|
||||||
await chatPage.createNewChat();
|
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.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
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.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ test.describe('chat activity with reasoning', () => {
|
||||||
await expect(reasoningElement).toBeVisible();
|
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.sendUserMessage('Why is the sky blue?');
|
||||||
await chatPage.isGenerationComplete();
|
await chatPage.isGenerationComplete();
|
||||||
|
|
||||||
|
|
|
||||||
207
tests/e2e/session.test.ts
Normal file
207
tests/e2e/session.test.ts
Normal file
|
|
@ -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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,34 +1,51 @@
|
||||||
import { expect as baseExpect, test as baseTest } from '@playwright/test';
|
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 {
|
interface Fixtures {
|
||||||
adaContext: UserContext;
|
adaContext: UserContext;
|
||||||
babbageContext: UserContext;
|
babbageContext: UserContext;
|
||||||
|
curieContext: UserContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = baseTest.extend<any, Fixtures>({
|
export const test = baseTest.extend<any, Fixtures>({
|
||||||
adaContext: [
|
adaContext: [
|
||||||
async ({ browser }, use) => {
|
async ({ browser }, use, workerInfo) => {
|
||||||
const ada = await createAuthenticatedContext({
|
const ada = await createAuthenticatedContext({
|
||||||
browser,
|
browser,
|
||||||
name: 'ada',
|
name: `ada-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await use(ada);
|
await use(ada);
|
||||||
await ada.context.close();
|
await ada.context.close();
|
||||||
},
|
},
|
||||||
{ scope: 'worker' },
|
{ scope: 'worker' },
|
||||||
],
|
],
|
||||||
babbageContext: [
|
babbageContext: [
|
||||||
async ({ browser }, use) => {
|
async ({ browser }, use, workerInfo) => {
|
||||||
const babbage = await createAuthenticatedContext({
|
const babbage = await createAuthenticatedContext({
|
||||||
browser,
|
browser,
|
||||||
name: 'babbage',
|
name: `babbage-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await use(babbage);
|
await use(babbage);
|
||||||
await babbage.context.close();
|
await babbage.context.close();
|
||||||
},
|
},
|
||||||
{ scope: 'worker' },
|
{ 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;
|
export const expect = baseExpect;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type Page,
|
type Page,
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import { generateId } from 'ai';
|
import { generateId } from 'ai';
|
||||||
|
import { ChatPage } from './pages/chat';
|
||||||
import { getUnixTime } from 'date-fns';
|
import { getUnixTime } from 'date-fns';
|
||||||
|
|
||||||
export type UserContext = {
|
export type UserContext = {
|
||||||
|
|
@ -19,22 +20,24 @@ export type UserContext = {
|
||||||
export async function createAuthenticatedContext({
|
export async function createAuthenticatedContext({
|
||||||
browser,
|
browser,
|
||||||
name,
|
name,
|
||||||
|
chatModel = 'chat-model',
|
||||||
}: {
|
}: {
|
||||||
browser: Browser;
|
browser: Browser;
|
||||||
name: string;
|
name: string;
|
||||||
|
chatModel?: 'chat-model' | 'chat-model-reasoning';
|
||||||
}): Promise<UserContext> {
|
}): Promise<UserContext> {
|
||||||
const authDir = path.join(__dirname, '../playwright/.auth');
|
const directory = path.join(__dirname, '../playwright/.sessions');
|
||||||
|
|
||||||
if (!fs.existsSync(authDir)) {
|
if (!fs.existsSync(directory)) {
|
||||||
fs.mkdirSync(authDir, { recursive: true });
|
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 context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
const email = `test-${name}-${getUnixTime(new Date())}@playwright.com`;
|
const email = `test-${name}@playwright.com`;
|
||||||
const password = generateId(16);
|
const password = generateId(16);
|
||||||
|
|
||||||
await page.goto('http://localhost:3000/register');
|
await page.goto('http://localhost:3000/register');
|
||||||
|
|
@ -48,6 +51,12 @@ export async function createAuthenticatedContext({
|
||||||
'Account created successfully!',
|
'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 context.storageState({ path: storageFile });
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|
||||||
|
|
@ -60,3 +69,13 @@ export async function createAuthenticatedContext({
|
||||||
request: newContext.request,
|
request: newContext.request,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateRandomTestUser() {
|
||||||
|
const email = `test-${getUnixTime(new Date())}@playwright.com`;
|
||||||
|
const password = generateId(16);
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
tests/pages/auth.ts
Normal file
65
tests/pages/auth.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import path from 'path';
|
import path from 'node:path';
|
||||||
import { chatModels } from '@/lib/ai/models';
|
import { chatModels } from '@/lib/ai/models';
|
||||||
import { expect, Page } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
export class ChatPage {
|
export class ChatPage {
|
||||||
constructor(private page: Page) {}
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ test.describe
|
||||||
test('Ada can invoke chat generation', async ({ adaContext }) => {
|
test('Ada can invoke chat generation', async ({ adaContext }) => {
|
||||||
const chatId = generateUUID();
|
const chatId = generateUUID();
|
||||||
|
|
||||||
const response = await adaContext.request.post('api/chat', {
|
const response = await adaContext.request.post('/api/chat', {
|
||||||
data: {
|
data: {
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: TEST_PROMPTS.SKY.MESSAGES,
|
messages: TEST_PROMPTS.SKY.MESSAGES,
|
||||||
|
|
@ -44,7 +44,7 @@ test.describe
|
||||||
}) => {
|
}) => {
|
||||||
const [chatId] = chatIdsCreatedByAda;
|
const [chatId] = chatIdsCreatedByAda;
|
||||||
|
|
||||||
const response = await babbageContext.request.post('api/chat', {
|
const response = await babbageContext.request.post('/api/chat', {
|
||||||
data: {
|
data: {
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: TEST_PROMPTS.GRASS.MESSAGES,
|
messages: TEST_PROMPTS.GRASS.MESSAGES,
|
||||||
|
|
@ -61,7 +61,7 @@ test.describe
|
||||||
const [chatId] = chatIdsCreatedByAda;
|
const [chatId] = chatIdsCreatedByAda;
|
||||||
|
|
||||||
const response = await babbageContext.request.delete(
|
const response = await babbageContext.request.delete(
|
||||||
`api/chat?id=${chatId}`,
|
`/api/chat?id=${chatId}`,
|
||||||
);
|
);
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
|
|
||||||
|
|
@ -72,7 +72,9 @@ test.describe
|
||||||
test('Ada can delete her own chat', async ({ adaContext }) => {
|
test('Ada can delete her own chat', async ({ adaContext }) => {
|
||||||
const [chatId] = chatIdsCreatedByAda;
|
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);
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
const deletedChat = await response.json();
|
const deletedChat = await response.json();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue