feat: add tests (#843)

This commit is contained in:
Jeremy 2025-03-04 17:25:46 -08:00 committed by GitHub
parent 95a2af2535
commit 9dd9a9898c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1063 additions and 191 deletions

72
.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: latest
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm test
- uses: actions/upload-artifact@v4
if: always() && !cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

7
.gitignore vendored
View file

@ -36,3 +36,10 @@ yarn-error.log*
.vercel
.vscode
.env*.local
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

View file

@ -3,7 +3,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button';
@ -25,9 +25,15 @@ export default function Page() {
useEffect(() => {
if (state.status === 'failed') {
toast.error('Invalid credentials!');
toast({
type: 'error',
description: 'Invalid credentials!',
});
} else if (state.status === 'invalid_data') {
toast.error('Failed validating your submission!');
toast({
type: 'error',
description: 'Failed validating your submission!',
});
} else if (state.status === 'success') {
setIsSuccessful(true);
router.refresh();

View file

@ -3,12 +3,12 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button';
import { register, type RegisterActionState } from '../actions';
import { toast } from '@/components/toast';
export default function Page() {
const router = useRouter();
@ -25,13 +25,17 @@ export default function Page() {
useEffect(() => {
if (state.status === 'user_exists') {
toast.error('Account already exists');
toast({ type: 'error', description: 'Account already exists!' });
} else if (state.status === 'failed') {
toast.error('Failed to create account');
toast({ type: 'error', description: 'Failed to create account!' });
} else if (state.status === 'invalid_data') {
toast.error('Failed validating your submission!');
toast({
type: 'error',
description: 'Failed validating your submission!',
});
} else if (state.status === 'success') {
toast.success('Account created successfully');
toast({ type: 'success', description: 'Account created successfully!' });
setIsSuccessful(true);
router.refresh();
}

View file

@ -9,7 +9,7 @@ import {
updateChatVisiblityById,
} from '@/lib/db/queries';
import { VisibilityType } from '@/components/visibility-selector';
import { myProvider } from '@/lib/ai/models';
import { myProvider } from '@/lib/ai/providers';
export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();

View file

@ -4,9 +4,7 @@ import {
smoothStream,
streamText,
} from 'ai';
import { auth } from '@/app/(auth)/auth';
import { myProvider } from '@/lib/ai/models';
import { systemPrompt } from '@/lib/ai/prompts';
import {
deleteChatById,
@ -19,113 +17,124 @@ import {
getMostRecentUserMessage,
sanitizeResponseMessages,
} from '@/lib/utils';
import { generateTitleFromUserMessage } from '../../actions';
import { createDocument } from '@/lib/ai/tools/create-document';
import { updateDocument } from '@/lib/ai/tools/update-document';
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
import { getWeather } from '@/lib/ai/tools/get-weather';
import { isProductionEnvironment } from '@/lib/constants';
import { NextResponse } from 'next/server';
import { myProvider } from '@/lib/ai/providers';
export const maxDuration = 60;
export async function POST(request: Request) {
const {
id,
messages,
selectedChatModel,
}: { id: string; messages: Array<Message>; selectedChatModel: string } =
await request.json();
try {
const {
id,
messages,
selectedChatModel,
}: {
id: string;
messages: Array<Message>;
selectedChatModel: string;
} = await request.json();
const session = await auth();
const session = await auth();
if (!session || !session.user || !session.user.id) {
return new Response('Unauthorized', { status: 401 });
}
if (!session || !session.user || !session.user.id) {
return new Response('Unauthorized', { status: 401 });
}
const userMessage = getMostRecentUserMessage(messages);
const userMessage = getMostRecentUserMessage(messages);
if (!userMessage) {
return new Response('No user message found', { status: 400 });
}
if (!userMessage) {
return new Response('No user message found', { status: 400 });
}
const chat = await getChatById({ id });
const chat = await getChatById({ id });
if (!chat) {
const title = await generateTitleFromUserMessage({ message: userMessage });
await saveChat({ id, userId: session.user.id, title });
}
if (!chat) {
const title = await generateTitleFromUserMessage({
message: userMessage,
});
await saveChat({ id, userId: session.user.id, title });
}
await saveMessages({
messages: [{ ...userMessage, createdAt: new Date(), chatId: id }],
});
await saveMessages({
messages: [{ ...userMessage, createdAt: new Date(), chatId: id }],
});
return createDataStreamResponse({
execute: (dataStream) => {
const result = streamText({
model: myProvider.languageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel }),
messages,
maxSteps: 5,
experimental_activeTools:
selectedChatModel === 'chat-model-reasoning'
? []
: [
'getWeather',
'createDocument',
'updateDocument',
'requestSuggestions',
],
experimental_transform: smoothStream({ chunking: 'word' }),
experimental_generateMessageId: generateUUID,
tools: {
getWeather,
createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({
session,
dataStream,
}),
},
onFinish: async ({ response, reasoning }) => {
if (session.user?.id) {
try {
const sanitizedResponseMessages = sanitizeResponseMessages({
messages: response.messages,
reasoning,
});
return createDataStreamResponse({
execute: (dataStream) => {
const result = streamText({
model: myProvider.languageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel }),
messages,
maxSteps: 5,
experimental_activeTools:
selectedChatModel === 'chat-model-reasoning'
? []
: [
'getWeather',
'createDocument',
'updateDocument',
'requestSuggestions',
],
experimental_transform: smoothStream({ chunking: 'word' }),
experimental_generateMessageId: generateUUID,
tools: {
getWeather,
createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({
session,
dataStream,
}),
},
onFinish: async ({ response, reasoning }) => {
if (session.user?.id) {
try {
const sanitizedResponseMessages = sanitizeResponseMessages({
messages: response.messages,
reasoning,
});
await saveMessages({
messages: sanitizedResponseMessages.map((message) => {
return {
id: message.id,
chatId: id,
role: message.role,
content: message.content,
createdAt: new Date(),
};
}),
});
} catch (error) {
console.error('Failed to save chat');
await saveMessages({
messages: sanitizedResponseMessages.map((message) => {
return {
id: message.id,
chatId: id,
role: message.role,
content: message.content,
createdAt: new Date(),
};
}),
});
} catch (error) {
console.error('Failed to save chat');
}
}
}
},
experimental_telemetry: {
isEnabled: true,
functionId: 'stream-text',
},
});
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
functionId: 'stream-text',
},
});
result.consumeStream();
result.consumeStream();
result.mergeIntoDataStream(dataStream, {
sendReasoning: true,
});
},
onError: () => {
return 'Oops, an error occured!';
},
});
result.mergeIntoDataStream(dataStream, {
sendReasoning: true,
});
},
onError: () => {
return 'Oops, an error occured!';
},
});
} catch (error) {
return NextResponse.json({ error }, { status: 400 });
}
}
export async function DELETE(request: Request) {

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import { streamObject } from 'ai';
import { myProvider } from '@/lib/ai/models';
import { myProvider } from '@/lib/ai/providers';
import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
import { createDocumentHandler } from '@/lib/artifacts/server';

View file

@ -1,4 +1,4 @@
import { myProvider } from '@/lib/ai/models';
import { myProvider } from '@/lib/ai/providers';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { experimental_generateImage } from 'ai';

View file

@ -1,4 +1,4 @@
import { myProvider } from '@/lib/ai/models';
import { myProvider } from '@/lib/ai/providers';
import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { streamObject } from 'ai';

View file

@ -1,5 +1,5 @@
import { smoothStream, streamText } from 'ai';
import { myProvider } from '@/lib/ai/models';
import { myProvider } from '@/lib/ai/providers';
import { createDocumentHandler } from '@/lib/artifacts/server';
import { updateDocumentPrompt } from '@/lib/ai/prompts';

View file

@ -44,6 +44,7 @@ function PureArtifactMessages({
key={message.id}
message={message}
isLoading={isLoading && index === messages.length - 1}
index={index}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)

View file

@ -155,7 +155,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<img
src={content.value}
alt="output"
className="rounded-md max-w-[600px] w-full"
className="rounded-md max-w-screen-toast-mobile w-full"
/>
</picture>
) : (

View file

@ -1153,3 +1153,22 @@ export const LineChartIcon = ({ size = 16 }: { size?: number }) => (
></path>
</svg>
);
export const WarningIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.55846 0.5C9.13413 0.5 9.65902 0.829456 9.90929 1.34788L15.8073 13.5653C16.1279 14.2293 15.6441 15 14.9068 15H1.09316C0.355835 15 -0.127943 14.2293 0.192608 13.5653L6.09065 1.34787C6.34092 0.829454 6.86581 0.5 7.44148 0.5H8.55846ZM8.74997 4.75V5.5V8V8.75H7.24997V8V5.5V4.75H8.74997ZM7.99997 12C8.55226 12 8.99997 11.5523 8.99997 11C8.99997 10.4477 8.55226 10 7.99997 10C7.44769 10 6.99997 10.4477 6.99997 11C6.99997 11.5523 7.44769 12 7.99997 12Z"
fill="currentColor"
/>
</svg>
);
};

View file

@ -50,6 +50,7 @@ export function MessageEditor({
return (
<div className="flex flex-col gap-2 w-full">
<Textarea
data-testid="message-editor"
ref={textareaRef}
className="bg-transparent outline-none overflow-hidden resize-none !text-base rounded-xl w-full"
value={draftContent}
@ -67,6 +68,7 @@ export function MessageEditor({
Cancel
</Button>
<Button
data-testid="message-editor-send-button"
variant="default"
className="h-fit py-2 px-3"
disabled={isSubmitting}

View file

@ -43,14 +43,15 @@ export function MessageReasoning({
) : (
<div className="flex flex-row gap-2 items-center">
<div className="font-medium">Reasoned for a few seconds</div>
<div
<button
type="button"
className="cursor-pointer"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ChevronDownIcon />
</div>
</button>
</div>
)}

View file

@ -3,17 +3,10 @@
import type { ChatRequestOptions, Message } from 'ai';
import cx from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useMemo, useState } from 'react';
import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import { DocumentToolCall, DocumentToolResult } from './document';
import {
ChevronDownIcon,
LoaderIcon,
PencilEditIcon,
SparklesIcon,
} from './icons';
import { PencilEditIcon, SparklesIcon } from './icons';
import { Markdown } from './markdown';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment';
@ -34,6 +27,7 @@ const PurePreviewMessage = ({
setMessages,
reload,
isReadonly,
index,
}: {
chatId: string;
message: Message;
@ -46,12 +40,14 @@ const PurePreviewMessage = ({
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
index: number;
}) => {
const [mode, setMode] = useState<'view' | 'edit'>('view');
return (
<AnimatePresence>
<motion.div
data-testid={`message-${message.role}-${index}`}
className="w-full mx-auto max-w-3xl px-4 group/message"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
@ -76,7 +72,10 @@ const PurePreviewMessage = ({
<div className="flex flex-col gap-4 w-full">
{message.experimental_attachments && (
<div className="flex flex-row justify-end gap-2">
<div
data-testid={`message-attachments-${index}`}
className="flex flex-row justify-end gap-2"
>
{message.experimental_attachments.map((attachment) => (
<PreviewAttachment
key={attachment.url}
@ -99,6 +98,7 @@ const PurePreviewMessage = ({
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid={`edit-${message.role}-${index}`}
variant="ghost"
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
onClick={() => {

View file

@ -43,6 +43,7 @@ function PureMessages({
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
index={index}
chatId={chatId}
message={message}
isLoading={isLoading && messages.length - 1 === index}

View file

@ -211,7 +211,10 @@ function PureMultimodalInput({
/>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div className="flex flex-row gap-2 overflow-x-scroll items-end">
<div
data-testid="attachments-preview"
className="flex flex-row gap-2 overflow-x-scroll items-end"
>
{attachments.map((attachment) => (
<PreviewAttachment key={attachment.url} attachment={attachment} />
))}
@ -231,6 +234,7 @@ function PureMultimodalInput({
)}
<Textarea
data-testid="multimodal-input"
ref={textareaRef}
placeholder="Send a message..."
value={input}
@ -293,6 +297,7 @@ function PureAttachmentsButton({
}) {
return (
<Button
data-testid="attachments-button"
className="rounded-md rounded-bl-lg p-[7px] h-fit dark:border-zinc-700 hover:dark:bg-zinc-900 hover:bg-zinc-200"
onClick={(event) => {
event.preventDefault();
@ -317,6 +322,7 @@ function PureStopButton({
}) {
return (
<Button
data-testid="stop-button"
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
onClick={(event) => {
event.preventDefault();
@ -342,6 +348,7 @@ function PureSendButton({
}) {
return (
<Button
data-testid="send-button"
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
onClick={(event) => {
event.preventDefault();

View file

@ -12,7 +12,7 @@ export const PreviewAttachment = ({
const { name, url, contentType } = attachment;
return (
<div className="flex flex-col gap-2">
<div data-testid="input-attachment-preview" className="flex flex-col gap-2">
<div className="w-20 h-16 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
{contentType ? (
contentType.startsWith('image') ? (
@ -32,7 +32,10 @@ export const PreviewAttachment = ({
)}
{isUploading && (
<div className="animate-spin absolute text-zinc-500">
<div
data-testid="input-attachment-loader"
className="animate-spin absolute text-zinc-500"
>
<LoaderIcon />
</div>
)}

View file

@ -38,7 +38,10 @@ function PureSuggestedActions({ chatId, append }: SuggestedActionsProps) {
];
return (
<div className="grid sm:grid-cols-2 gap-2 w-full">
<div
data-testid="suggested-actions"
className="grid sm:grid-cols-2 gap-2 w-full"
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}

44
components/toast.tsx Normal file
View file

@ -0,0 +1,44 @@
'use client';
import React, { ReactNode } from 'react';
import { toast as sonnerToast } from 'sonner';
import { CheckCircleFillIcon, WarningIcon } from './icons';
const iconsByType: Record<'success' | 'error', ReactNode> = {
success: <CheckCircleFillIcon />,
error: <WarningIcon />,
};
export function toast(props: Omit<ToastProps, 'id'>) {
return sonnerToast.custom((id) => (
<Toast id={id} type={props.type} description={props.description} />
));
}
function Toast(props: ToastProps) {
const { id, type, description } = props;
return (
<div className="flex w-full toast-mobile:w-[356px] justify-center">
<div
data-testid="toast"
key={id}
className="bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-2 items-center"
>
<div
data-type={type}
className="data-[type=error]:text-red-600 data-[type=success]:text-green-600"
>
{iconsByType[type]}
</div>
<div className="text-zinc-950 text-sm">{description}</div>
</div>
</div>
);
}
interface ToastProps {
id: string | number;
type: 'success' | 'error';
description: string;
}

View file

@ -147,7 +147,7 @@ The server file processes the document for the artifact. It streams updates (if
```ts
import { smoothStream, streamText } from "ai";
import { myProvider } from "@/lib/ai/models";
import { myProvider } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
import { updateDocumentPrompt } from "@/lib/ai/prompts";

226
lib/ai/models.test.ts Normal file
View file

@ -0,0 +1,226 @@
import { CoreMessage, FinishReason, simulateReadableStream } from 'ai';
import { MockLanguageModelV1 } from 'ai/test';
interface TextDeltaChunk {
type: 'text-delta';
textDelta: string;
}
interface FinishChunk {
type: 'finish';
finishReason: FinishReason;
logprobs: undefined;
usage: { completionTokens: number; promptTokens: number };
}
type Chunk = TextDeltaChunk | FinishChunk;
const getResponseChunksByPrompt = (prompt: CoreMessage[]): Array<Chunk> => {
const userMessage = prompt.at(-1);
if (!userMessage) {
throw new Error('No user message found');
}
if (
compareMessages(userMessage, {
role: 'user',
content: [{ type: 'text', text: 'why is grass green?' }],
})
) {
return [
{ type: 'text-delta', textDelta: "it's " },
{ type: 'text-delta', textDelta: 'just ' },
{ type: 'text-delta', textDelta: 'green ' },
{ type: 'text-delta', textDelta: 'duh!' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
];
} else if (
compareMessages(userMessage, {
role: 'user',
content: [{ type: 'text', text: 'why is the sky blue?' }],
})
) {
return [
{ type: 'text-delta', textDelta: "it's " },
{ type: 'text-delta', textDelta: 'just ' },
{ type: 'text-delta', textDelta: 'blue ' },
{ type: 'text-delta', textDelta: 'duh!' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
];
} else if (
compareMessages(userMessage, {
role: 'user',
content: [
{ type: 'text', text: 'What are the advantages of using Next.js?' },
],
})
) {
return [
{ type: 'text-delta', textDelta: 'with ' },
{ type: 'text-delta', textDelta: 'next.js ' },
{ type: 'text-delta', textDelta: 'you ' },
{ type: 'text-delta', textDelta: 'can ' },
{ type: 'text-delta', textDelta: 'ship ' },
{ type: 'text-delta', textDelta: 'fast! ' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
];
} else if (
compareMessages(userMessage, {
role: 'user',
content: [
{
type: 'text',
text: 'who painted this?',
},
{
type: 'image',
image: '...',
},
],
})
) {
return [
{ type: 'text-delta', textDelta: 'this ' },
{ type: 'text-delta', textDelta: 'painting ' },
{ type: 'text-delta', textDelta: 'is ' },
{ type: 'text-delta', textDelta: 'by ' },
{ type: 'text-delta', textDelta: 'monet!' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
];
}
return [];
};
export const chatModel = new MockLanguageModelV1({
doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
text: `Hello, world!`,
}),
doStream: async ({ prompt }) => ({
stream: simulateReadableStream({
chunks: getResponseChunksByPrompt(prompt),
}),
rawCall: { rawPrompt: null, rawSettings: {} },
}),
});
export const reasoningModel = new MockLanguageModelV1({
doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
text: `Hello, world!`,
}),
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: 'test' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
],
}),
rawCall: { rawPrompt: null, rawSettings: {} },
}),
});
export const titleModel = new MockLanguageModelV1({
doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
text: `This is a test title`,
}),
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: 'This is a test title' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
],
}),
rawCall: { rawPrompt: null, rawSettings: {} },
}),
});
export const artifactModel = new MockLanguageModelV1({
doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 20 },
text: `Hello, world!`,
}),
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: 'test' },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
],
}),
rawCall: { rawPrompt: null, rawSettings: {} },
}),
});
function compareMessages(msg1: CoreMessage, msg2: CoreMessage): boolean {
if (msg1.role !== msg2.role) return false;
if (!Array.isArray(msg1.content) || !Array.isArray(msg2.content)) {
return false;
}
if (msg1.content.length !== msg2.content.length) return false;
for (let i = 0; i < msg1.content.length; i++) {
const item1 = msg1.content[i];
const item2 = msg2.content[i];
if (item1.type !== item2.type) return false;
if (item1.type === 'image' && item2.type === 'image') {
// if (item1.image.toString() !== item2.image.toString()) return false;
// if (item1.mimeType !== item2.mimeType) return false;
} else if (item1.type === 'text' && item2.type === 'text') {
if (item1.text !== item2.text) return false;
} else {
return false;
}
}
return true;
}

View file

@ -1,30 +1,5 @@
import { openai } from '@ai-sdk/openai';
import { fireworks } from '@ai-sdk/fireworks';
import {
customProvider,
extractReasoningMiddleware,
wrapLanguageModel,
} from 'ai';
export const DEFAULT_CHAT_MODEL: string = 'chat-model-small';
export const myProvider = customProvider({
languageModels: {
'chat-model-small': openai('gpt-4o-mini'),
'chat-model-large': openai('gpt-4o'),
'chat-model-reasoning': wrapLanguageModel({
model: fireworks('accounts/fireworks/models/deepseek-r1'),
middleware: extractReasoningMiddleware({ tagName: 'think' }),
}),
'title-model': openai('gpt-4-turbo'),
'artifact-model': openai('gpt-4o-mini'),
},
imageModels: {
'small-model': openai.image('dall-e-2'),
'large-model': openai.image('dall-e-3'),
},
});
interface ChatModel {
id: string;
name: string;

41
lib/ai/providers.ts Normal file
View file

@ -0,0 +1,41 @@
import {
customProvider,
extractReasoningMiddleware,
wrapLanguageModel,
} from 'ai';
import { openai } from '@ai-sdk/openai';
import { fireworks } from '@ai-sdk/fireworks';
import { isTestEnvironment } from '../constants';
import {
artifactModel,
chatModel,
reasoningModel,
titleModel,
} from './models.test';
export const myProvider = isTestEnvironment
? customProvider({
languageModels: {
'chat-model-small': chatModel,
'chat-model-large': chatModel,
'chat-model-reasoning': reasoningModel,
'title-model': titleModel,
'artifact-model': artifactModel,
},
})
: customProvider({
languageModels: {
'chat-model-small': openai('gpt-4o-mini'),
'chat-model-large': openai('gpt-4o'),
'chat-model-reasoning': wrapLanguageModel({
model: fireworks('accounts/fireworks/models/deepseek-r1'),
middleware: extractReasoningMiddleware({ tagName: 'think' }),
}),
'title-model': openai('gpt-4-turbo'),
'artifact-model': openai('gpt-4o-mini'),
},
imageModels: {
'small-model': openai.image('dall-e-2'),
'large-model': openai.image('dall-e-3'),
},
});

View file

@ -4,7 +4,7 @@ import { DataStreamWriter, streamObject, tool } from 'ai';
import { getDocumentById, saveSuggestions } from '@/lib/db/queries';
import { Suggestion } from '@/lib/db/schema';
import { generateUUID } from '@/lib/utils';
import { myProvider } from '../models';
import { myProvider } from '../providers';
interface RequestSuggestionsProps {
session: Session;

7
lib/constants.ts Normal file
View file

@ -0,0 +1,7 @@
export const isProductionEnvironment = process.env.NODE_ENV === 'production';
export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT_TEST_BASE_URL ||
process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT,
);

View file

@ -15,10 +15,11 @@
"db:push": "drizzle-kit push",
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up"
"db:up": "drizzle-kit up",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test --workers=4"
},
"dependencies": {
"@ai-sdk/fireworks": "^0.1.8",
"@ai-sdk/fireworks": "0.1.8",
"@ai-sdk/openai": "1.1.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6",
@ -38,7 +39,7 @@
"@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1",
"@vercel/postgres": "^0.10.0",
"ai": "4.1.44",
"ai": "4.1.50",
"bcrypt-ts": "^5.0.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
@ -83,6 +84,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@playwright/test": "^1.50.1",
"@tailwindcss/typography": "^0.5.15",
"@types/d3-scale": "^4.0.8",
"@types/node": "^22.8.6",

105
playwright.config.ts Normal file
View file

@ -0,0 +1,105 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import { config } from 'dotenv';
config({
path: '.env.local',
});
/* Use process.env.PORT by default and fallback to port 3000 */
const PORT = process.env.PORT || 3000;
/**
* Set webServer.url and use.baseURL with the location
* of the WebServer respecting the correct set port
*/
const baseURL = `http://localhost:${PORT}`;
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* 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. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure global timeout for each test */
timeout: 30000,
expect: {
timeout: 30000,
},
/* Configure projects for major browsers */
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm dev',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
});

125
pnpm-lock.yaml generated
View file

@ -9,7 +9,7 @@ importers:
.:
dependencies:
'@ai-sdk/fireworks':
specifier: ^0.1.8
specifier: 0.1.8
version: 0.1.8(zod@3.23.8)
'@ai-sdk/openai':
specifier: 1.1.9
@ -61,7 +61,7 @@ importers:
version: 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@vercel/analytics':
specifier: ^1.3.1
version: 1.3.2(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
version: 1.3.2(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@vercel/blob':
specifier: ^0.24.1
version: 0.24.1
@ -69,8 +69,8 @@ importers:
specifier: ^0.10.0
version: 0.10.0
ai:
specifier: 4.1.44
version: 4.1.44(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
specifier: 4.1.50
version: 4.1.50(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
bcrypt-ts:
specifier: ^5.0.2
version: 5.0.2
@ -106,7 +106,7 @@ importers:
version: 11.11.10(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
geist:
specifier: ^1.3.1
version: 1.3.1(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
version: 1.3.1(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
lucide-react:
specifier: ^0.446.0
version: 0.446.0(react@19.0.0-rc-45804af1-20241021)
@ -115,10 +115,10 @@ importers:
version: 5.0.8
next:
specifier: 15.0.3-canary.2
version: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
version: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
next-auth:
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
version: 5.0.0-beta.25(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
@ -198,6 +198,9 @@ importers:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
'@playwright/test':
specifier: ^1.50.1
version: 1.50.1
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.14)
@ -270,8 +273,8 @@ packages:
peerDependencies:
zod: ^3.0.0
'@ai-sdk/provider-utils@2.1.6':
resolution: {integrity: sha512-Pfyaj0QZS22qyVn5Iz7IXcJ8nKIKlu2MeSAdKJzTwkAks7zdLaKVB+396Rqcp1bfQnxl7vaduQVMQiXUrgK8Gw==}
'@ai-sdk/provider-utils@2.1.10':
resolution: {integrity: sha512-4GZ8GHjOFxePFzkl3q42AU0DQOtTQ5w09vmaWUf/pKFXJPizlnzKSUkF0f+VkapIUfDugyMqPMT1ge8XQzVI7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
@ -279,8 +282,8 @@ packages:
zod:
optional: true
'@ai-sdk/provider-utils@2.1.9':
resolution: {integrity: sha512-NerKjTuuUUs6glJGaentaXEBH52jRM0pR+cRCzc7aWke/K5jYBD6Frv1JYBpcxS7gnnCqSQZR9woiyS+6jrdjw==}
'@ai-sdk/provider-utils@2.1.6':
resolution: {integrity: sha512-Pfyaj0QZS22qyVn5Iz7IXcJ8nKIKlu2MeSAdKJzTwkAks7zdLaKVB+396Rqcp1bfQnxl7vaduQVMQiXUrgK8Gw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
@ -292,12 +295,12 @@ packages:
resolution: {integrity: sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g==}
engines: {node: '>=18'}
'@ai-sdk/provider@1.0.8':
resolution: {integrity: sha512-f9jSYwKMdXvm44Dmab1vUBnfCDSFfI5rOtvV1W9oKB7WYHR5dGvCC6x68Mk3NUfrdmNoMVHGoh6JT9HCVMlMow==}
'@ai-sdk/provider@1.0.9':
resolution: {integrity: sha512-jie6ZJT2ZR0uVOVCDc9R2xCX5I/Dum/wEK28lx21PJx6ZnFAN9EzD2WsPhcDWfCgGx3OAZZ0GyM3CEobXpa9LA==}
engines: {node: '>=18'}
'@ai-sdk/react@1.1.17':
resolution: {integrity: sha512-NAuEflFvjw1uh1AOmpyi7rBF4xasWsiWUb86JQ8ScjDGxoGDYEdBnaHOxUpooLna0dGNbSPkvDMnVRhoLKoxPQ==}
'@ai-sdk/react@1.1.20':
resolution: {integrity: sha512-4QOM9fR9SryaRraybckDjrhl1O6XejqELdKmrM5g9y9eLnWAfjwF+W1aN0knkSHzbbjMqN77sy9B9yL8EuJbDw==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -308,8 +311,8 @@ packages:
zod:
optional: true
'@ai-sdk/ui-utils@1.1.15':
resolution: {integrity: sha512-NsV/3CMmjc4m53snzRdtZM6teTQUXIKi8u0Kf7GBruSzaMSuZ4DWaAAlUshhR3p2FpZgtsogW+vYG1/rXsGu+Q==}
'@ai-sdk/ui-utils@1.1.16':
resolution: {integrity: sha512-jfblR2yZVISmNK2zyNzJZFtkgX57WDAUQXcmn3XUBJyo8LFsADu+/vYMn5AOyBi9qJT0RBk11PEtIxIqvByw3Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
@ -1130,6 +1133,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.50.1':
resolution: {integrity: sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@ -1653,8 +1661,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
ai@4.1.44:
resolution: {integrity: sha512-2THAHlSdZkRemTu7XinH5wckO3QprrniKg31fczgg4RKvsow0OztsLalwcIGoo69S0WN4BErNKkBisT+wXREZg==}
ai@4.1.50:
resolution: {integrity: sha512-YBNeemrJKDrxoBQd3V9aaxhKm5q5YyRcF7PZE7W0NmLuvsdva/1aQNYTAsxs47gQFdvqfYmlFy4B0E+356OlPA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -2349,6 +2357,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -3106,6 +3119,16 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
playwright-core@1.50.1:
resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.50.1:
resolution: {integrity: sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@ -3806,18 +3829,18 @@ snapshots:
'@ai-sdk/provider-utils': 2.1.6(zod@3.23.8)
zod: 3.23.8
'@ai-sdk/provider-utils@2.1.6(zod@3.23.8)':
'@ai-sdk/provider-utils@2.1.10(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.7
'@ai-sdk/provider': 1.0.9
eventsource-parser: 3.0.0
nanoid: 3.3.8
secure-json-parse: 2.7.0
optionalDependencies:
zod: 3.23.8
'@ai-sdk/provider-utils@2.1.9(zod@3.23.8)':
'@ai-sdk/provider-utils@2.1.6(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.8
'@ai-sdk/provider': 1.0.7
eventsource-parser: 3.0.0
nanoid: 3.3.8
secure-json-parse: 2.7.0
@ -3828,24 +3851,24 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@1.0.8':
'@ai-sdk/provider@1.0.9':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.1.17(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
'@ai-sdk/react@1.1.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
dependencies:
'@ai-sdk/provider-utils': 2.1.9(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.15(zod@3.23.8)
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.16(zod@3.23.8)
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
throttleit: 2.1.0
optionalDependencies:
react: 19.0.0-rc-45804af1-20241021
zod: 3.23.8
'@ai-sdk/ui-utils@1.1.15(zod@3.23.8)':
'@ai-sdk/ui-utils@1.1.16(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.8
'@ai-sdk/provider-utils': 2.1.9(zod@3.23.8)
'@ai-sdk/provider': 1.0.9
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8)
zod-to-json-schema: 3.24.1(zod@3.23.8)
optionalDependencies:
zod: 3.23.8
@ -4427,6 +4450,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.50.1':
dependencies:
playwright: 1.50.1
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.0': {}
@ -4921,11 +4948,11 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vercel/analytics@1.3.2(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
'@vercel/analytics@1.3.2(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
dependencies:
server-only: 0.0.1
optionalDependencies:
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react: 19.0.0-rc-45804af1-20241021
'@vercel/blob@0.24.1':
@ -4949,12 +4976,12 @@ snapshots:
acorn@8.14.0: {}
ai@4.1.44(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
ai@4.1.50(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
dependencies:
'@ai-sdk/provider': 1.0.8
'@ai-sdk/provider-utils': 2.1.9(zod@3.23.8)
'@ai-sdk/react': 1.1.17(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.15(zod@3.23.8)
'@ai-sdk/provider': 1.0.9
'@ai-sdk/provider-utils': 2.1.10(zod@3.23.8)
'@ai-sdk/react': 1.1.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.1.16(zod@3.23.8)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
optionalDependencies:
@ -5778,6 +5805,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -5792,9 +5822,9 @@ snapshots:
functions-have-names@1.2.3: {}
geist@1.3.1(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)):
geist@1.3.1(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)):
dependencies:
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
get-intrinsic@1.2.4:
dependencies:
@ -6573,10 +6603,10 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.25(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
next-auth@5.0.0-beta.25(next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
'@auth/core': 0.37.2
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
next: 15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react: 19.0.0-rc-45804af1-20241021
next-themes@0.3.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
@ -6584,7 +6614,7 @@ snapshots:
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
next@15.0.3-canary.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
'@next/env': 15.0.3-canary.2
'@swc/counter': 0.1.3
@ -6605,6 +6635,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 15.0.3-canary.2
'@next/swc-win32-x64-msvc': 15.0.3-canary.2
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.50.1
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
@ -6739,6 +6770,14 @@ snapshots:
pirates@4.0.6: {}
playwright-core@1.50.1: {}
playwright@1.50.1:
dependencies:
playwright-core: 1.50.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.0.0: {}
postcss-import@15.1.0(postcss@8.4.47):
@ -6779,7 +6818,7 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -13,6 +13,9 @@ const config: Config = {
mono: ['geist-mono'],
},
extend: {
screens: {
'toast-mobile': '600px',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',

77
tests/auth.test.ts Normal file
View file

@ -0,0 +1,77 @@
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';
import { test, expect, 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();
});
});

194
tests/chat.test.ts Normal file
View file

@ -0,0 +1,194 @@
import fs from 'fs';
import path from 'path';
import { test, expect, Page } from '@playwright/test';
class ChatPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/');
}
public getCurrentURL(): string {
return this.page.url();
}
async sendUserMessage(message: string) {
await this.page.getByTestId('multimodal-input').click();
await this.page.getByTestId('multimodal-input').fill(message);
await this.page.getByTestId('send-button').click();
await this.page.getByTestId('message-user-0').isVisible();
expect(await this.page.getByTestId('message-user-0').innerText()).toContain(
message,
);
}
async getAssistantResponse() {
return this.page.getByTestId('message-assistant-1').innerText();
}
async isGenerationComplete() {
await expect(this.page.getByTestId('send-button')).toBeVisible();
await this.page.waitForTimeout(2000);
}
async hasChatIdInUrl() {
await expect(this.page).toHaveURL(
/^http:\/\/localhost:3000\/chat\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
}
async sendUserMessageFromSuggestion() {
await this.page
.getByRole('button', { name: 'What are the advantages of' })
.click();
}
async editAndResendUserMessage(message: string) {
await this.page.getByTestId('edit-user-0').click();
await this.page.getByTestId('message-editor').fill(message);
await this.page.getByTestId('message-editor-send-button').click();
await this.page.getByTestId('message-user-0').isVisible();
await expect(
this.page.getByTestId('message-editor-send-button'),
).not.toBeVisible();
expect(await this.page.getByTestId('message-user-0').innerText()).toContain(
message,
);
}
async isElementVisible(elementId: string) {
await expect(this.page.getByTestId(elementId)).toBeVisible();
}
async isElementNotVisible(elementId: string) {
await expect(this.page.getByTestId(elementId)).not.toBeVisible();
}
async addImageAttachment() {
this.page.on('filechooser', async (fileChooser) => {
const filePath = path.join(
process.cwd(),
'public',
'images',
'mouth of the seine, monet.jpg',
);
const imageBuffer = fs.readFileSync(filePath);
await fileChooser.setFiles({
name: 'mouth of the seine, monet.jpg',
mimeType: 'image/jpeg',
buffer: imageBuffer,
});
});
await this.page.getByTestId('attachments-button').click();
}
public get sendButton() {
return this.page.getByTestId('send-button');
}
public get stopButton() {
return this.page.getByTestId('stop-button');
}
public get multimodalInput() {
return this.page.getByTestId('multimodal-input');
}
}
test.describe('chat activity', () => {
let chatPage: ChatPage;
test.beforeEach(async ({ page }) => {
chatPage = new ChatPage(page);
await chatPage.goto();
});
test('send a user message and receive response', async () => {
await chatPage.sendUserMessage('why is grass green?');
await chatPage.isGenerationComplete();
expect(await chatPage.getAssistantResponse()).toContain(
"it's just green duh!",
);
});
test('redirect to /chat/:id after submitting message', async () => {
await chatPage.sendUserMessage('why is grass green?');
await chatPage.isGenerationComplete();
expect(await chatPage.getAssistantResponse()).toContain(
"it's just green duh!",
);
await chatPage.hasChatIdInUrl();
});
test('send a user message from suggestion', async () => {
await chatPage.sendUserMessageFromSuggestion();
await chatPage.isGenerationComplete();
expect(await chatPage.getAssistantResponse()).toContain(
'with next.js you can ship fast!',
);
});
test('toggle between send/stop button based on activity', async () => {
await expect(chatPage.sendButton).toBeVisible();
await expect(chatPage.sendButton).toBeDisabled();
await chatPage.sendUserMessage('why is grass green?');
await expect(chatPage.sendButton).not.toBeVisible();
await expect(chatPage.stopButton).toBeVisible();
await chatPage.isGenerationComplete();
await expect(chatPage.stopButton).not.toBeVisible();
await expect(chatPage.sendButton).toBeVisible();
});
test('stop generation during submission', async () => {
await chatPage.sendUserMessage('why is grass green?');
await chatPage.stopButton.click();
await expect(chatPage.sendButton).toBeVisible();
});
test('edit user message and resubmit', async () => {
await chatPage.sendUserMessage('why is grass green?');
await chatPage.isGenerationComplete();
expect(await chatPage.getAssistantResponse()).toContain(
"it's just green duh!",
);
await chatPage.editAndResendUserMessage('why is the sky blue?');
await chatPage.isGenerationComplete();
expect(await chatPage.getAssistantResponse()).toContain(
"it's just blue duh!",
);
});
test('hide suggested actions after sending message', async () => {
await chatPage.isElementVisible('suggested-actions');
await chatPage.sendUserMessageFromSuggestion();
await chatPage.isElementNotVisible('suggested-actions');
});
test('handle file upload and send image attachment with message', async () => {
await chatPage.addImageAttachment();
await chatPage.isElementVisible('attachments-preview');
await chatPage.isElementVisible('input-attachment-loader');
await chatPage.isElementNotVisible('input-attachment-loader');
await chatPage.sendUserMessage('who painted this?');
await chatPage.isElementVisible('message-attachments-0');
await chatPage.isGenerationComplete();
await chatPage.isElementVisible('message-assistant-1');
expect(await chatPage.getAssistantResponse()).toContain(
'this painting is by monet!',
);
});
});

24
tests/global.setup.ts Normal file
View file

@ -0,0 +1,24 @@
import path from '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/user.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 });
});