feat: add tests (#843)
This commit is contained in:
parent
95a2af2535
commit
9dd9a9898c
35 changed files with 1063 additions and 191 deletions
72
.github/workflows/playwright.yml
vendored
Normal file
72
.github/workflows/playwright.yml
vendored
Normal 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
7
.gitignore
vendored
|
|
@ -36,3 +36,10 @@ yarn-error.log*
|
|||
.vercel
|
||||
.vscode
|
||||
.env*.local
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
44
components/toast.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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
226
lib/ai/models.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
41
lib/ai/providers.ts
Normal 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'),
|
||||
},
|
||||
});
|
||||
|
|
@ -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
7
lib/constants.ts
Normal 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,
|
||||
);
|
||||
|
|
@ -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
105
playwright.config.ts
Normal 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
125
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
BIN
public/images/mouth of the seine, monet.jpg
Normal file
BIN
public/images/mouth of the seine, monet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -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
77
tests/auth.test.ts
Normal 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
194
tests/chat.test.ts
Normal 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
24
tests/global.setup.ts
Normal 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 });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue