mirror of
https://github.com/vee1e/workorder-desk.git
synced 2026-09-01 09:50:13 +00:00
merge(feat/ai-cov-frontend): coverage gate green
This commit is contained in:
commit
9d6258f21e
4 changed files with 692 additions and 0 deletions
181
frontend/src/api/stream.test.ts
Normal file
181
frontend/src/api/stream.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { postEventStream } from './stream';
|
||||
|
||||
interface MockReader {
|
||||
read: ReturnType<typeof vi.fn>;
|
||||
releaseLock: ReturnType<typeof vi.fn>;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function okResponse(chunks: Uint8Array[]): { ok: true; status: number; body: { getReader: () => MockReader }; json: ReturnType<typeof vi.fn> } {
|
||||
const reads: Array<{ done: boolean; value: Uint8Array }> = [
|
||||
...chunks.map((value) => ({ done: false, value })),
|
||||
{ done: true, value: new Uint8Array(0) },
|
||||
];
|
||||
const reader: MockReader = {
|
||||
read: vi.fn().mockImplementation(() => Promise.resolve(reads.shift()!)),
|
||||
releaseLock: vi.fn(),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { getReader: () => reader },
|
||||
json: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(status: number, body: unknown): {
|
||||
ok: false;
|
||||
status: number;
|
||||
body: null;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return { ok: false, status, body: null, json: vi.fn().mockResolvedValue(body) };
|
||||
}
|
||||
|
||||
describe('postEventStream', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('emits parsed events and calls onDone for a full SSE frame', async () => {
|
||||
const enc = new TextEncoder();
|
||||
const frame =
|
||||
'event: token\ndata: {"content":"hi"}\n\n' +
|
||||
'event: message_done\ndata: {"runId":"r1","content":"hi"}\n\n';
|
||||
const fetchMock = vi.fn().mockResolvedValue(okResponse([enc.encode(frame)]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith('token', { content: 'hi' });
|
||||
expect(onEvent).toHaveBeenCalledWith('message_done', { runId: 'r1', content: 'hi' });
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/ai/sessions/s1/messages',
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({ content: 'hi' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('buffers a frame split across chunks', async () => {
|
||||
const enc = new TextEncoder();
|
||||
const bytes = enc.encode('event: token\ndata: {"content":"hi"}\n\n');
|
||||
const half = Math.floor(bytes.length / 2);
|
||||
const fetchMock = vi.fn().mockResolvedValue(okResponse([bytes.slice(0, half), bytes.slice(half)]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
expect(onEvent).toHaveBeenCalledWith('token', { content: 'hi' });
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips malformed data payloads without breaking the stream', async () => {
|
||||
const enc = new TextEncoder();
|
||||
const bytes = enc.encode(
|
||||
'event: token\ndata: {not json}\n\nevent: message_done\ndata: {"runId":"r1","content":"done"}\n\n',
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse([bytes])));
|
||||
|
||||
const onEvent = vi.fn();
|
||||
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
expect(onEvent).toHaveBeenCalledWith('message_done', { runId: 'r1', content: 'done' });
|
||||
});
|
||||
|
||||
it('refreshes once on a pre-first-byte 401 and retries the stream', async () => {
|
||||
const enc = new TextEncoder();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(401, { error: { message: 'Unauthorized', code: 'UNAUTHORIZED', requestId: 'r-1' } }),
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn() })
|
||||
.mockResolvedValueOnce(okResponse([enc.encode('event: token\ndata: {"content":"ok"}\n\n')]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toBe('/api/v1/auth/refresh');
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: 'POST' }));
|
||||
expect(onEvent).toHaveBeenCalledWith('token', { content: 'ok' });
|
||||
});
|
||||
|
||||
it('throws a Session expired error when refresh fails after a 401', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(401, { error: { message: 'Unauthorized', code: 'UNAUTHORIZED' } }),
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: false, status: 401, json: vi.fn() });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent: vi.fn() }),
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED', status: 401, message: 'Session expired' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws an ApiError when the response is not ok and not 401', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
errorResponse(500, { error: { message: 'Boom', code: 'INTERNAL' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
await expect(
|
||||
postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone }),
|
||||
).rejects.toMatchObject({ code: 'INTERNAL', status: 500, message: 'Boom' });
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops silently when the stream is aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
let rejectRead!: (e: Error) => void;
|
||||
const reader: MockReader = {
|
||||
read: vi.fn().mockImplementation(
|
||||
() => new Promise((_resolve, reject) => { rejectRead = reject; }),
|
||||
),
|
||||
releaseLock: vi.fn(),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { getReader: () => reader },
|
||||
json: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
const streamPromise = postEventStream(
|
||||
'/ai/sessions/s1/messages',
|
||||
{ content: 'hi' },
|
||||
{ signal: controller.signal, onEvent, onDone },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(reader.read).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
rejectRead(new DOMException('Aborted', 'AbortError'));
|
||||
await streamPromise;
|
||||
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
expect(reader.cancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
245
frontend/src/features/admin/queries.test.tsx
Normal file
245
frontend/src/features/admin/queries.test.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useAgentConfig,
|
||||
useAgentRunDetail,
|
||||
useAgentRuns,
|
||||
useAdminUsers,
|
||||
useDisableAgent,
|
||||
useManualTriageRun,
|
||||
useMetrics,
|
||||
useToggleAi,
|
||||
useUpdateAgentConfig,
|
||||
useUpdateRole,
|
||||
useUpdateStatus,
|
||||
} from './queries';
|
||||
import { createQueryClient } from '../../test/utils';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api } from '../../api/client';
|
||||
|
||||
function makeWrapper(qc: QueryClient) {
|
||||
return function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultWrapper = makeWrapper(createQueryClient());
|
||||
|
||||
const user = {
|
||||
id: 'u1',
|
||||
email: 'a@b.com',
|
||||
name: 'A',
|
||||
role: 'user' as const,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
aiEnabled: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const config = {
|
||||
name: 'triage',
|
||||
enabled: true,
|
||||
mode: 'suggest' as const,
|
||||
allowedFields: ['priority'],
|
||||
dailyActionCap: 5,
|
||||
flagThreshold: 'medium' as const,
|
||||
workingHours: '08:00-18:00',
|
||||
updatedBy: null,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const run = {
|
||||
id: 'r1',
|
||||
mode: 'copilot' as const,
|
||||
actorId: null,
|
||||
status: 'complete' as const,
|
||||
model: 'gpt-4o',
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
finishedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const page = { items: [user], page: 1, limit: 20, total: 1 };
|
||||
|
||||
describe('admin queries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches admin users with filters', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
|
||||
const { result } = renderHook(() => useAdminUsers(1, 20, 'admin', 'alice'), {
|
||||
wrapper: defaultWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/admin/users?page=1&limit=20&role=admin&search=alice');
|
||||
});
|
||||
|
||||
it('fetches the agent config', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(config);
|
||||
const { result } = renderHook(() => useAgentConfig(), { wrapper: defaultWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/admin/agents/triage');
|
||||
expect(result.current.data?.mode).toBe('suggest');
|
||||
});
|
||||
|
||||
it('updates the agent config', async () => {
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockResolvedValue({ ...config, mode: 'auto-apply' });
|
||||
const { result } = renderHook(() => useUpdateAgentConfig(), { wrapper: defaultWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
mode: 'auto-apply',
|
||||
dailyActionCap: 10,
|
||||
flagThreshold: 'high',
|
||||
workingHours: '09:00-17:00',
|
||||
});
|
||||
});
|
||||
|
||||
expect(api.patch).toHaveBeenCalledWith('/admin/agents/triage/config', {
|
||||
mode: 'auto-apply',
|
||||
dailyActionCap: 10,
|
||||
flagThreshold: 'high',
|
||||
workingHours: '09:00-17:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the agent', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: false });
|
||||
const { result } = renderHook(() => useDisableAgent(), { wrapper: defaultWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(undefined);
|
||||
});
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/admin/agents/disable');
|
||||
});
|
||||
|
||||
it('runs triage with and without a work order id', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ outcome: 'done' });
|
||||
const { result } = renderHook(() => useManualTriageRun(), { wrapper: defaultWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('wo1');
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/admin/agents/triage/run', { workOrderId: 'wo1' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(undefined);
|
||||
});
|
||||
expect(api.post).toHaveBeenLastCalledWith('/admin/agents/triage/run', {});
|
||||
});
|
||||
|
||||
it('lists agent runs', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [run],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
});
|
||||
const { result } = renderHook(() => useAgentRuns(1, 20), { wrapper: defaultWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/admin/agents/runs?page=1&limit=20');
|
||||
});
|
||||
|
||||
it('fetches an agent run detail only when an id is provided', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({ run, messages: [], toolCalls: [] });
|
||||
const { result } = renderHook(() => useAgentRunDetail('r1'), { wrapper: defaultWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(api.get).toHaveBeenCalledWith('/admin/agents/runs/r1');
|
||||
|
||||
renderHook(() => useAgentRunDetail(null), { wrapper: defaultWrapper });
|
||||
expect(api.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('optimistically toggles aiEnabled in the admin users cache', async () => {
|
||||
const qc = createQueryClient();
|
||||
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
|
||||
qc.setQueryData(key, page);
|
||||
|
||||
let resolvePatch!: (v: unknown) => void;
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => new Promise((resolve) => { resolvePatch = resolve; }),
|
||||
);
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
|
||||
|
||||
const { result } = renderHook(() => useToggleAi(), { wrapper: makeWrapper(qc) });
|
||||
|
||||
let mutatePromise: Promise<unknown> | undefined;
|
||||
act(() => {
|
||||
mutatePromise = result.current.mutateAsync({ id: 'u1', aiEnabled: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
|
||||
expect(data?.items[0]?.aiEnabled).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolvePatch(user);
|
||||
await mutatePromise;
|
||||
});
|
||||
|
||||
expect(api.patch).toHaveBeenCalledWith('/admin/users/u1/ai', { aiEnabled: true });
|
||||
});
|
||||
|
||||
it('optimistically updates a user role and rolls back on error', async () => {
|
||||
const qc = createQueryClient();
|
||||
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
|
||||
qc.setQueryData(key, page);
|
||||
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('nope'));
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
|
||||
|
||||
const { result } = renderHook(() => useUpdateRole(), { wrapper: makeWrapper(qc) });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ id: 'u1', role: 'admin' })).rejects.toThrow('nope');
|
||||
});
|
||||
|
||||
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
|
||||
expect(data?.items[0]?.role).toBe('user');
|
||||
});
|
||||
|
||||
it('optimistically updates a user status and rolls back on error', async () => {
|
||||
const qc = createQueryClient();
|
||||
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
|
||||
qc.setQueryData(key, page);
|
||||
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('nope'));
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
|
||||
|
||||
const { result } = renderHook(() => useUpdateStatus(), { wrapper: makeWrapper(qc) });
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ id: 'u1', isActive: false })).rejects.toThrow('nope');
|
||||
});
|
||||
|
||||
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
|
||||
expect(data?.items[0]?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches metrics', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({ users: 1, workOrders: 2, uptimeSeconds: 3 });
|
||||
const { result } = renderHook(() => useMetrics(), { wrapper: defaultWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/admin/metrics');
|
||||
});
|
||||
});
|
||||
66
frontend/src/features/copilot/queries.test.tsx
Normal file
66
frontend/src/features/copilot/queries.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCopilotSession, useDecideApproval } from './queries';
|
||||
import { createQueryClient } from '../../test/utils';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api } from '../../api/client';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: 's1',
|
||||
userId: 'u1',
|
||||
status: 'active' as const,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('copilot queries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reuses an active session when one exists', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
session,
|
||||
{ ...session, id: 's2', status: 'archived' as const },
|
||||
]);
|
||||
const { result } = renderHook(() => useCopilotSession(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/ai/sessions');
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
expect(result.current.data?.id).toBe('s1');
|
||||
});
|
||||
|
||||
it('creates a new session when none is active', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ ...session, id: 'old', status: 'expired' as const },
|
||||
]);
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue(session);
|
||||
const { result } = renderHook(() => useCopilotSession(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/sessions');
|
||||
expect(result.current.data?.id).toBe('s1');
|
||||
});
|
||||
|
||||
it('decides a tool approval', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'tc1', runId: 'r1' });
|
||||
const { result } = renderHook(() => useDecideApproval(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({ id: 'tc1', approve: true });
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/ai/tool-calls/tc1/decide', { approve: true });
|
||||
});
|
||||
});
|
||||
200
frontend/src/hooks/useCopilotStream.test.tsx
Normal file
200
frontend/src/hooks/useCopilotStream.test.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { useCopilotStream } from '../features/copilot/useCopilotStream';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ postEventStream: vi.fn() }));
|
||||
|
||||
vi.mock('../api/stream', () => ({ postEventStream: mocks.postEventStream }));
|
||||
|
||||
function streamEvents(events: Array<[string, Record<string, unknown>]>) {
|
||||
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
|
||||
for (const [event, data] of events) opts.onEvent(event, data);
|
||||
opts.onDone?.();
|
||||
});
|
||||
}
|
||||
|
||||
describe('useCopilotStream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('appends tokens into one assistant message and finalizes on message_done', async () => {
|
||||
streamEvents([
|
||||
['heartbeat', { ts: 1 }],
|
||||
['token', { content: 'Hel' }],
|
||||
['token', { content: 'lo' }],
|
||||
['message_done', { runId: 'r1', content: 'Hello' }],
|
||||
]);
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: 'Hello' },
|
||||
]);
|
||||
expect(result.current.runId).toBe('r1');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(mocks.postEventStream).toHaveBeenCalledWith(
|
||||
'/ai/sessions/s1/messages',
|
||||
{ content: 'hi' },
|
||||
expect.objectContaining({ onEvent: expect.any(Function), onDone: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append an empty final message', async () => {
|
||||
streamEvents([
|
||||
['token', { content: 'Hi' }],
|
||||
['message_done', { runId: 'r1', content: '' }],
|
||||
]);
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: 'Hi' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes a pendingApproval until a tool_result resolves it', async () => {
|
||||
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
|
||||
opts.onEvent('tool_approval_required', {
|
||||
toolCallId: 'tc1',
|
||||
tool: 'update_work_order',
|
||||
args: { title: 'x' },
|
||||
summary: 'Update wo1',
|
||||
expiresAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
await Promise.resolve();
|
||||
opts.onEvent('tool_result', { toolCallId: 'tc1', outcome: 'executed', result: 'ok' });
|
||||
opts.onDone?.();
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
sendPromise = result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.pendingApproval).toEqual(
|
||||
expect.objectContaining({ toolCallId: 'tc1', tool: 'update_work_order', summary: 'Update wo1' }),
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(result.current.pendingApproval).toBeNull();
|
||||
expect(result.current.toolResults).toEqual({ tc1: 'ok' });
|
||||
});
|
||||
|
||||
it('clears a pendingApproval when it expires', async () => {
|
||||
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
|
||||
opts.onEvent('tool_approval_required', {
|
||||
toolCallId: 'tc1',
|
||||
tool: 'update_work_order',
|
||||
args: {},
|
||||
summary: 'Update wo1',
|
||||
expiresAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
await Promise.resolve();
|
||||
opts.onEvent('tool_approval_expired', { toolCallId: 'tc1' });
|
||||
opts.onDone?.();
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
let sendPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
sendPromise = result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.pendingApproval).not.toBeNull());
|
||||
await act(async () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(result.current.pendingApproval).toBeNull();
|
||||
});
|
||||
|
||||
it('does not send while a stream is in flight', async () => {
|
||||
let resolveStream!: () => void;
|
||||
mocks.postEventStream.mockImplementation(
|
||||
() => new Promise<void>((resolve) => { resolveStream = resolve; }),
|
||||
);
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
let first: Promise<void> | undefined;
|
||||
let second: Promise<void> | undefined;
|
||||
act(() => {
|
||||
first = result.current.send('one', 's1');
|
||||
second = result.current.send('two', 's1');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveStream();
|
||||
});
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(mocks.postEventStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send while an approval is pending', async () => {
|
||||
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
|
||||
opts.onEvent('tool_approval_required', {
|
||||
toolCallId: 'tc1',
|
||||
tool: 'update_work_order',
|
||||
args: {},
|
||||
summary: 'Update wo1',
|
||||
expiresAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
opts.onDone?.();
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
await act(async () => {
|
||||
await result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
expect(result.current.pendingApproval).not.toBeNull();
|
||||
await act(async () => {
|
||||
await result.current.send('again', 's1');
|
||||
});
|
||||
|
||||
expect(mocks.postEventStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces an error event message', async () => {
|
||||
streamEvents([['error', { code: 'AI_UNAVAILABLE', message: 'AI provider unavailable' }]]);
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('AI provider unavailable');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('sets the error when the stream onError callback fires', async () => {
|
||||
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
|
||||
opts.onError?.(new Error('stream boom'));
|
||||
});
|
||||
const { result } = renderHook(() => useCopilotStream());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send('hi', 's1');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('stream boom');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue