Render task messages (#52)

This commit is contained in:
Chris Estreich 2025-05-30 09:43:25 -07:00 committed by GitHub
parent 8e2b7c048e
commit dd4bb88396
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1613 additions and 447 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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);
};

View file

@ -0,0 +1,2 @@
export * from './events';
export * from './messages';

View 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());
};

View 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>&middot;</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;

View file

@ -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>
);
};

View file

@ -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',

View file

@ -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} />

View file

@ -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;
}
}
}

View 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);
});
});

View file

@ -50,3 +50,6 @@ export function formatCurrency(
maximumFractionDigits: 2,
}).format(value);
}
export const formatTimestamp = (timestamp: number) =>
new Date(timestamp * 1000).toLocaleString();

View file

@ -0,0 +1,2 @@
export * from './message';
export * from './task';

View 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>;

View 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>;

View file

@ -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';
`);

View file

@ -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
View 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.
},
},
]);