mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
Render task messages (#52)
This commit is contained in:
parent
8e2b7c048e
commit
dd4bb88396
18 changed files with 1613 additions and 447 deletions
27
package.json
27
package.json
|
|
@ -27,9 +27,9 @@
|
|||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/localizations": "^3.16.2",
|
||||
"@clerk/nextjs": "^6.20.1",
|
||||
"@clerk/themes": "^2.2.47",
|
||||
"@clerk/localizations": "^3.16.3",
|
||||
"@clerk/nextjs": "^6.20.2",
|
||||
"@clerk/themes": "^2.2.48",
|
||||
"@clickhouse/client": "^1.11.1",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@logtail/pino": "^0.5.5",
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
"@sentry/nextjs": "^9.23.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.6",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-query": "^5.79.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"lucide-react": "^0.509.0",
|
||||
"next": "^15.3.2",
|
||||
"next": "^15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"next-typesafe-url": "^5.1.7",
|
||||
|
|
@ -70,20 +70,21 @@
|
|||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-use": "^17.6.0",
|
||||
"sonner": "^2.0.3",
|
||||
"stripe": "^18.1.1",
|
||||
"stripe": "^18.2.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.32"
|
||||
"zod": "^3.25.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clerk/testing": "^1.7.4",
|
||||
"@clerk/testing": "^1.7.5",
|
||||
"@dotenvx/dotenvx": "^1.44.1",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@next/bundle-analyzer": "^15.3.2",
|
||||
"@next/eslint-plugin-next": "^15.3.2",
|
||||
"@next/bundle-analyzer": "^15.3.3",
|
||||
"@next/eslint-plugin-next": "^15.3.3",
|
||||
"@percy/cli": "1.30.10",
|
||||
"@percy/playwright": "^1.0.8",
|
||||
"@playwright/test": "^1.52.0",
|
||||
|
|
@ -99,7 +100,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/node": "^22.15.27",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/react": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.4",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"start-server-and-test": "^2.0.12",
|
||||
|
|
@ -129,7 +130,7 @@
|
|||
"tailwindcss": "^4.1.8",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.4",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.33.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
|
|
|
|||
1263
pnpm-lock.yaml
generated
1263
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '@roo-code/types';
|
||||
|
||||
import type { TimePeriod } from '@/types';
|
||||
import { type Task, taskSchema } from '@/types/analytics';
|
||||
import { analytics } from '@/lib/server';
|
||||
import { type User, getUsersById } from '@/db/server';
|
||||
|
||||
|
|
@ -224,26 +225,11 @@ export const getModelUsage = async ({
|
|||
* getTasks
|
||||
*/
|
||||
|
||||
const taskSchema = z.object({
|
||||
taskId: z.string(),
|
||||
userId: z.string(),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
completed: z.coerce.boolean(),
|
||||
tokens: z.coerce.number(),
|
||||
cost: z.coerce.number(),
|
||||
timestamp: z.coerce.number(),
|
||||
});
|
||||
|
||||
export type Task = z.infer<typeof taskSchema> & {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const getTasks = async ({
|
||||
orgId,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
}): Promise<Task[]> => {
|
||||
}): Promise<(Task & { user: User })[]> => {
|
||||
if (!orgId) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -283,5 +269,5 @@ export const getTasks = async ({
|
|||
|
||||
return tasks
|
||||
.map((usage) => ({ ...usage, user: users[usage.userId] }))
|
||||
.filter((usage): usage is Task => !!usage.user);
|
||||
.filter((usage): usage is Task & { user: User } => !!usage.user);
|
||||
};
|
||||
2
src/actions/analytics/index.ts
Normal file
2
src/actions/analytics/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './events';
|
||||
export * from './messages';
|
||||
25
src/actions/analytics/messages.ts
Normal file
25
src/actions/analytics/messages.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { messageSchema, type Message } from '@/types/analytics';
|
||||
import { analytics } from '@/lib/server';
|
||||
|
||||
/**
|
||||
* getMessages
|
||||
*/
|
||||
|
||||
export const getMessages = async (taskId: string): Promise<Message[]> => {
|
||||
const results = await analytics.query({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM messages
|
||||
WHERE taskId = {taskId: String}
|
||||
ORDER BY ts ASC
|
||||
`,
|
||||
format: 'JSONEachRow',
|
||||
query_params: { taskId },
|
||||
});
|
||||
|
||||
return z.array(messageSchema).parse(await results.json());
|
||||
};
|
||||
61
src/app/(authenticated)/usage/Messages.tsx
Normal file
61
src/app/(authenticated)/usage/Messages.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import type { Message } from '@/types/analytics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTimestamp } from '@/lib/formatters';
|
||||
|
||||
type MessagesProps = {
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export const Messages = ({ messages }: MessagesProps) => {
|
||||
const conversation = useMemo(
|
||||
() =>
|
||||
messages
|
||||
.filter(isVisible)
|
||||
.map((message, index) => decorate({ message, index })),
|
||||
[messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{conversation.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg p-3',
|
||||
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<div>{message.name}</div>
|
||||
<div>·</div>
|
||||
<div>{message.timestamp}</div>
|
||||
</div>
|
||||
<div className="text-sm markdown-prose">
|
||||
<ReactMarkdown>{message.text}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const decorate = ({ message, index }: { message: Message; index: number }) => {
|
||||
const role =
|
||||
index === 0 || message.say === 'user_feedback' ? 'user' : 'assistant';
|
||||
|
||||
const name = role === 'user' ? 'User' : 'Roo Code';
|
||||
const timestamp = formatTimestamp(message.timestamp);
|
||||
|
||||
return { ...message, role, name, timestamp };
|
||||
};
|
||||
|
||||
const isVisible = (message: Message) =>
|
||||
(message.ask === 'text' ||
|
||||
message.say === 'text' ||
|
||||
message.say === 'completion_result' ||
|
||||
message.say === 'user_feedback') &&
|
||||
typeof message.text === 'string' &&
|
||||
message.text.length > 0;
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { Task } from '@/actions/analytics';
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import { getMessages } from '@/actions/analytics';
|
||||
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||
import {
|
||||
Drawer,
|
||||
|
|
@ -12,114 +15,78 @@ import {
|
|||
} from '@/components/ui';
|
||||
|
||||
import { Status } from './Status';
|
||||
import { Messages } from './Messages';
|
||||
|
||||
type TaskDrawerProps = {
|
||||
task: Task;
|
||||
task: Task & { user: User };
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => (
|
||||
<Drawer open={true} onOpenChange={onClose} direction="right">
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Task</DrawerTitle>
|
||||
<DrawerDescription>{task.taskId}</DrawerDescription>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="p-4">
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Developer</span>
|
||||
<span className="max-w-64 truncate">{task.user.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Provider</span>
|
||||
<span className="max-w-64 truncate font-mono">{task.provider}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Model</span>
|
||||
<span className="max-w-64 truncate font-mono">{task.model}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Tokens</span>
|
||||
<span className="max-w-64 truncate font-mono">
|
||||
{formatNumber(task.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Cost</span>
|
||||
<span className="max-w-64 truncate font-mono">
|
||||
{formatCurrency(task.cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Date</span>
|
||||
<span className="max-w-64 truncate">
|
||||
{new Date(task.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Status completed={task.completed} />
|
||||
</div>
|
||||
</div>
|
||||
export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => {
|
||||
const { data: messages } = useQuery({
|
||||
queryKey: ['messages', task.taskId],
|
||||
queryFn: () => getMessages(task.taskId),
|
||||
});
|
||||
|
||||
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
|
||||
<div className="space-y-4">
|
||||
{conversation.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`rounded-lg p-3 ${
|
||||
message.role === 'user'
|
||||
? 'ml-4 bg-primary/10'
|
||||
: message.role === 'assistant'
|
||||
? 'mr-4 bg-secondary/10'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{message.role.charAt(0).toUpperCase() + message.role.slice(1)} •
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="text-sm">{message.content}</div>
|
||||
return (
|
||||
<Drawer open={true} onOpenChange={onClose} direction="right">
|
||||
<DrawerContent className="flex flex-col h-full">
|
||||
<DrawerHeader className="flex-shrink-0">
|
||||
<DrawerTitle>Task</DrawerTitle>
|
||||
<DrawerDescription>{task.taskId}</DrawerDescription>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Developer</span>
|
||||
<span className="max-w-64 truncate">{task.user.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Provider</span>
|
||||
<span className="max-w-64 truncate font-mono">
|
||||
{task.provider}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Model</span>
|
||||
<span className="max-w-64 truncate font-mono">{task.model}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Tokens</span>
|
||||
<span className="max-w-64 truncate font-mono">
|
||||
{formatNumber(task.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Cost</span>
|
||||
<span className="max-w-64 truncate font-mono">
|
||||
{formatCurrency(task.cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Date</span>
|
||||
<span className="max-w-64 truncate">
|
||||
{new Date(task.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Status completed={task.completed} />
|
||||
</div>
|
||||
</div>
|
||||
{typeof messages !== 'undefined' && messages.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-2 text-lg font-medium">Conversation</h3>
|
||||
<Messages messages={messages} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const conversation = [
|
||||
{
|
||||
id: 'msg1',
|
||||
role: 'user',
|
||||
content:
|
||||
'Create a React component that displays a list of items with pagination.',
|
||||
timestamp: new Date('2025-05-01T10:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'msg2',
|
||||
role: 'assistant',
|
||||
content:
|
||||
"I'll create a React component for displaying a paginated list. Here's how we can implement it...",
|
||||
timestamp: new Date('2025-05-01T10:00:30'),
|
||||
},
|
||||
{
|
||||
id: 'msg3',
|
||||
role: 'user',
|
||||
content: 'Can you add sorting functionality as well?',
|
||||
timestamp: new Date('2025-05-01T10:02:00'),
|
||||
},
|
||||
{
|
||||
id: 'msg4',
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Certainly! I'll add sorting functionality to the component. Here's the updated implementation...",
|
||||
timestamp: new Date('2025-05-01T10:02:30'),
|
||||
},
|
||||
];
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import type { ColumnDef } from '@tanstack/react-table';
|
|||
import { useAuth } from '@clerk/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { type Task, getTasks } from '@/actions/analytics';
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import { getTasks } from '@/actions/analytics';
|
||||
import { formatNumber, formatCurrency } from '@/lib/formatters';
|
||||
import { Button, Skeleton } from '@/components/ui';
|
||||
import { DataTable } from '@/components/layout/DataTable';
|
||||
|
|
@ -18,7 +20,7 @@ export const Tasks = ({
|
|||
}: {
|
||||
filter: Filter | null;
|
||||
onFilter: (filter: Filter) => void;
|
||||
onTaskSelected: (task: Task) => void;
|
||||
onTaskSelected: (task: Task & { user: User }) => void;
|
||||
}) => {
|
||||
const { orgId } = useAuth();
|
||||
|
||||
|
|
@ -40,7 +42,7 @@ export const Tasks = ({
|
|||
);
|
||||
}, [filter, data]);
|
||||
|
||||
const cols: ColumnDef<Task>[] = useMemo(
|
||||
const cols: ColumnDef<Task & { user: User }>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Task ID',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useState, useCallback } from 'react';
|
|||
import { useTranslations } from 'next-intl';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { Task } from '@/actions/analytics';
|
||||
import type { User } from '@/db/server';
|
||||
import type { Task } from '@/types/analytics';
|
||||
import { Badge, Button } from '@/components/ui';
|
||||
import { UsageCard } from '@/components/usage';
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export const Usage = () => {
|
|||
const t = useTranslations('Analytics');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
|
||||
const [filter, setFilter] = useState<Filter | null>(null);
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [task, setTask] = useState<(Task & { user: User }) | null>(null);
|
||||
|
||||
const onFilter = useCallback((filter: Filter) => {
|
||||
setFilter(filter);
|
||||
|
|
@ -61,7 +62,7 @@ export const Usage = () => {
|
|||
<Tasks
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
onTaskSelected={(task: Task) => setTask(task)}
|
||||
onTaskSelected={(task: Task & { user: User }) => setTask(task)}
|
||||
/>
|
||||
) : viewMode === 'developers' ? (
|
||||
<Developers onFilter={onFilter} />
|
||||
|
|
|
|||
|
|
@ -217,3 +217,61 @@
|
|||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.markdown-prose {
|
||||
& > * {
|
||||
@apply mb-3;
|
||||
}
|
||||
& > *:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
& h1 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
& h2 {
|
||||
@apply text-base font-bold;
|
||||
}
|
||||
& h3 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
& h4,
|
||||
& h5,
|
||||
& h6 {
|
||||
@apply font-medium;
|
||||
}
|
||||
& p {
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
& code {
|
||||
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
|
||||
}
|
||||
& pre {
|
||||
@apply bg-muted p-3 rounded overflow-x-auto;
|
||||
& code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
}
|
||||
& blockquote {
|
||||
@apply border-l-4 border-muted-foreground pl-4 italic;
|
||||
}
|
||||
& ul {
|
||||
@apply list-disc ml-6;
|
||||
}
|
||||
& ol {
|
||||
@apply list-decimal ml-6;
|
||||
}
|
||||
& li {
|
||||
@apply mb-1;
|
||||
}
|
||||
& a {
|
||||
@apply text-primary underline hover:no-underline;
|
||||
}
|
||||
& strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
& em {
|
||||
@apply italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
320
src/hooks/__tests__/useAuthState.test.ts
Normal file
320
src/hooks/__tests__/useAuthState.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// npx vitest run src/hooks/__tests__/useAuthState.test.ts
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useSessionStorage, useMount } from 'react-use';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { AuthStateParam } from '@/types';
|
||||
import { EXTENSION_URI_SCHEME } from '@/lib/constants';
|
||||
|
||||
import { useAuthState, useSetAuthState } from '../useAuthState';
|
||||
|
||||
const mockSetState = vi.fn();
|
||||
const mockSetAuthRedirect = vi.fn();
|
||||
const mockUseMount = vi.fn();
|
||||
|
||||
vi.mock('react-use', () => ({
|
||||
useSessionStorage: vi.fn(),
|
||||
useMount: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSearchParams = new Map<string, string>();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: vi.fn(() => ({
|
||||
get: (key: string) => mockSearchParams.get(key) || null,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseSessionStorage = vi.mocked(useSessionStorage);
|
||||
const mockedUseMount = vi.mocked(useMount);
|
||||
const mockedUseSearchParams = vi.mocked(useSearchParams);
|
||||
|
||||
describe('useAuthState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.clear();
|
||||
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return [defaultValue, mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return [defaultValue, mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
mockedUseMount.mockImplementation(mockUseMount);
|
||||
|
||||
mockedUseSearchParams.mockReturnValue({
|
||||
get: (key: string) => mockSearchParams.get(key) || null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with undefined values when no search params are present', () => {
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current.state).toBeUndefined();
|
||||
expect(result.current.authRedirect).toBeUndefined();
|
||||
expect(result.current.params).toBeUndefined();
|
||||
expect(typeof result.current.set).toBe('function');
|
||||
});
|
||||
|
||||
it('should initialize with values from search params', () => {
|
||||
mockSearchParams.set(AuthStateParam.State, 'test-state');
|
||||
mockSearchParams.set(AuthStateParam.AuthRedirect, 'test-redirect');
|
||||
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return ['test-state', mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return ['test-redirect', mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current.state).toBe('test-state');
|
||||
expect(result.current.authRedirect).toBe('test-redirect');
|
||||
expect(result.current.params).toBeInstanceOf(URLSearchParams);
|
||||
expect(result.current.params?.get(AuthStateParam.State)).toBe(
|
||||
'test-state',
|
||||
);
|
||||
expect(result.current.params?.get(AuthStateParam.AuthRedirect)).toBe(
|
||||
'test-redirect',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use EXTENSION_URI_SCHEME as default authRedirect in params when authRedirect is undefined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return ['test-state', mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return [undefined, mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current.state).toBe('test-state');
|
||||
expect(result.current.authRedirect).toBeUndefined();
|
||||
expect(result.current.params).toBeInstanceOf(URLSearchParams);
|
||||
expect(result.current.params?.get(AuthStateParam.State)).toBe(
|
||||
'test-state',
|
||||
);
|
||||
expect(result.current.params?.get(AuthStateParam.AuthRedirect)).toBe(
|
||||
EXTENSION_URI_SCHEME,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('params generation', () => {
|
||||
it('should not generate params when state is undefined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return [undefined, mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return ['test-redirect', mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current.state).toBeUndefined();
|
||||
expect(result.current.authRedirect).toBe('test-redirect');
|
||||
expect(result.current.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate params when state is defined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return ['test-state', mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return ['test-redirect', mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current.params).toBeInstanceOf(URLSearchParams);
|
||||
expect(result.current.params?.get(AuthStateParam.State)).toBe(
|
||||
'test-state',
|
||||
);
|
||||
expect(result.current.params?.get(AuthStateParam.AuthRedirect)).toBe(
|
||||
'test-redirect',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set function', () => {
|
||||
it('should call setState and setAuthRedirect with correct values', () => {
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
const newAuthState = {
|
||||
state: 'new-state',
|
||||
authRedirect: 'new-redirect',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.set(newAuthState);
|
||||
});
|
||||
|
||||
expect(mockSetState).toHaveBeenCalledWith('new-state');
|
||||
expect(mockSetAuthRedirect).toHaveBeenCalledWith('new-redirect');
|
||||
});
|
||||
|
||||
it('should handle undefined values in set function', () => {
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
const newAuthState = {
|
||||
state: undefined,
|
||||
authRedirect: undefined,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.set(newAuthState);
|
||||
});
|
||||
|
||||
expect(mockSetState).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSetAuthRedirect).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value structure', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const { result } = renderHook(() => useAuthState());
|
||||
|
||||
expect(result.current).toHaveProperty('state');
|
||||
expect(result.current).toHaveProperty('authRedirect');
|
||||
expect(result.current).toHaveProperty('params');
|
||||
expect(result.current).toHaveProperty('set');
|
||||
expect(typeof result.current.set).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSetAuthState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.clear();
|
||||
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return [defaultValue, mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return [defaultValue, mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
mockedUseMount.mockImplementation((callback) => {
|
||||
mockUseMount.mockImplementation(callback);
|
||||
});
|
||||
|
||||
mockedUseSearchParams.mockReturnValue({
|
||||
get: (key: string) => mockSearchParams.get(key) || null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should not call set when state is undefined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return [undefined, mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return [undefined, mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
renderHook(() => useSetAuthState());
|
||||
|
||||
expect(mockedUseMount).toHaveBeenCalled();
|
||||
|
||||
// Execute the mount callback
|
||||
const mountCallback = mockedUseMount.mock.calls[0]?.[0];
|
||||
expect(mountCallback).toBeDefined();
|
||||
mountCallback?.();
|
||||
|
||||
expect(mockSetState).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthRedirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call set when state is defined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return ['test-state', mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return ['test-redirect', mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
renderHook(() => useSetAuthState());
|
||||
|
||||
expect(mockedUseMount).toHaveBeenCalled();
|
||||
|
||||
// Execute the mount callback
|
||||
const mountCallback = mockedUseMount.mock.calls[0]?.[0];
|
||||
expect(mountCallback).toBeDefined();
|
||||
mountCallback?.();
|
||||
|
||||
expect(mockSetState).toHaveBeenCalledWith('test-state');
|
||||
expect(mockSetAuthRedirect).toHaveBeenCalledWith('test-redirect');
|
||||
});
|
||||
|
||||
it('should use EXTENSION_URI_SCHEME as default authRedirect when undefined', () => {
|
||||
mockedUseSessionStorage.mockImplementation(
|
||||
(key: string, defaultValue: unknown) => {
|
||||
if (key === AuthStateParam.State) {
|
||||
return ['test-state', mockSetState];
|
||||
}
|
||||
if (key === AuthStateParam.AuthRedirect) {
|
||||
return [undefined, mockSetAuthRedirect];
|
||||
}
|
||||
return [defaultValue, vi.fn()];
|
||||
},
|
||||
);
|
||||
|
||||
renderHook(() => useSetAuthState());
|
||||
|
||||
expect(mockedUseMount).toHaveBeenCalled();
|
||||
|
||||
const mountCallback = mockedUseMount.mock.calls[0]?.[0];
|
||||
expect(mountCallback).toBeDefined();
|
||||
mountCallback?.();
|
||||
|
||||
expect(mockSetState).toHaveBeenCalledWith('test-state');
|
||||
expect(mockSetAuthRedirect).toHaveBeenCalledWith(EXTENSION_URI_SCHEME);
|
||||
});
|
||||
});
|
||||
|
|
@ -50,3 +50,6 @@ export function formatCurrency(
|
|||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp * 1000).toLocaleString();
|
||||
|
|
|
|||
2
src/types/analytics/index.ts
Normal file
2
src/types/analytics/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './message';
|
||||
export * from './task';
|
||||
18
src/types/analytics/message.ts
Normal file
18
src/types/analytics/message.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const messageSchema = z.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
taskId: z.string(),
|
||||
ts: z.number(),
|
||||
type: z.enum(['ask', 'say']),
|
||||
ask: z.string().nullable(),
|
||||
say: z.string().nullable(),
|
||||
text: z.string().nullable(),
|
||||
reasoning: z.string().nullable(),
|
||||
partial: z.boolean().nullable(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
export type Message = z.infer<typeof messageSchema>;
|
||||
14
src/types/analytics/task.ts
Normal file
14
src/types/analytics/task.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const taskSchema = z.object({
|
||||
taskId: z.string(),
|
||||
userId: z.string(),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
completed: z.coerce.boolean(),
|
||||
tokens: z.coerce.number(),
|
||||
cost: z.coerce.number(),
|
||||
timestamp: z.coerce.number(),
|
||||
});
|
||||
|
||||
export type Task = z.infer<typeof taskSchema>;
|
||||
|
|
@ -4,12 +4,18 @@ import { Env } from '@/lib/server';
|
|||
import { testDb, disconnect } from '@/db/server';
|
||||
|
||||
async function resetTestDatabase() {
|
||||
const db = testDb!;
|
||||
const db = testDb;
|
||||
|
||||
// Skip database reset if no database connection is available
|
||||
if (!db) {
|
||||
console.log('No database connection available, skipping database reset');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tables = await db.execute<{ table_name: string }>(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE';
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
globals: true, // This is needed by @testing-library to be cleaned up after each test.
|
||||
include: ['src/**/*.test.{js,jsx,ts,tsx}'],
|
||||
coverage: {
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/**/*.stories.{js,jsx,ts,tsx}', '**/*.d.ts'],
|
||||
},
|
||||
environment: 'node', // jsdom
|
||||
setupFiles: './vitest-setup.ts',
|
||||
globalSetup: './vitest-global-setup.ts',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
37
vitest.workspace.ts
Normal file
37
vitest.workspace.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineWorkspace } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineWorkspace([
|
||||
// Server-side tests (node environment).
|
||||
{
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
name: 'server',
|
||||
globals: true,
|
||||
include: [
|
||||
'src/**/*.test.{js,jsx,ts,tsx}',
|
||||
'!src/hooks/**/*.test.{js,jsx,ts,tsx}', // Exclude hooks tests.
|
||||
'!src/components/**/*.test.{js,jsx,ts,tsx}', // Exclude component tests.
|
||||
],
|
||||
environment: 'node',
|
||||
setupFiles: './vitest-setup.ts',
|
||||
globalSetup: './vitest-global-setup.ts',
|
||||
},
|
||||
},
|
||||
// Client-side tests (jsdom environment).
|
||||
{
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
name: 'client',
|
||||
globals: true,
|
||||
include: [
|
||||
'src/hooks/**/*.test.{js,jsx,ts,tsx}',
|
||||
'src/components/**/*.test.{js,jsx,ts,tsx}',
|
||||
],
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest-setup.ts',
|
||||
// No globalSetup for client tests to avoid database connection issues.
|
||||
},
|
||||
},
|
||||
]);
|
||||
Loading…
Add table
Reference in a new issue