This commit is contained in:
661
packages/admin-web/tests/admin-pages.test.ts
Normal file
661
packages/admin-web/tests/admin-pages.test.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Admin web — comprehensive page component and API module tests.
|
||||
*
|
||||
* Tests module exports, API client methods, URL construction,
|
||||
* and rendering of each page component in various states.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { App } from '../src/App.js';
|
||||
import { Dashboard } from '../src/pages/Dashboard.js';
|
||||
import { Members } from '../src/pages/Members.js';
|
||||
import { Capabilities } from '../src/pages/Capabilities.js';
|
||||
import { Jobs } from '../src/pages/Jobs.js';
|
||||
import { Audit } from '../src/pages/Audit.js';
|
||||
import { TeamSettings } from '../src/pages/TeamSettings.js';
|
||||
import { Analytics } from '../src/pages/Analytics.js';
|
||||
import { api } from '../src/api.js';
|
||||
|
||||
// ─── API Client Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe('API client', () => {
|
||||
it('exports team management methods', () => {
|
||||
expect(typeof api.listTeams).toBe('function');
|
||||
expect(typeof api.getTeam).toBe('function');
|
||||
expect(typeof api.updateTeam).toBe('function');
|
||||
expect(typeof api.inviteMember).toBe('function');
|
||||
expect(typeof api.removeMember).toBe('function');
|
||||
expect(typeof api.updateMemberRole).toBe('function');
|
||||
});
|
||||
|
||||
it('exports job and audit methods', () => {
|
||||
expect(typeof api.listJobs).toBe('function');
|
||||
expect(typeof api.listCron).toBe('function');
|
||||
expect(typeof api.listAudit).toBe('function');
|
||||
expect(typeof api.getStats).toBe('function');
|
||||
});
|
||||
|
||||
it('exports analytics method', () => {
|
||||
expect(typeof api.getAnalytics).toBe('function');
|
||||
});
|
||||
|
||||
it('exports task management method', () => {
|
||||
expect(typeof api.listTasks).toBe('function');
|
||||
});
|
||||
|
||||
it('exports capability governance methods', () => {
|
||||
expect(typeof api.listCapabilityPolicies).toBe('function');
|
||||
expect(typeof api.updateCapabilityPolicy).toBe('function');
|
||||
expect(typeof api.listCapabilityOverrides).toBe('function');
|
||||
expect(typeof api.createCapabilityOverride).toBe('function');
|
||||
expect(typeof api.deleteCapabilityOverride).toBe('function');
|
||||
expect(typeof api.listCapabilityRequests).toBe('function');
|
||||
expect(typeof api.decideCapabilityRequest).toBe('function');
|
||||
});
|
||||
|
||||
it('exports scout and suggestions methods', () => {
|
||||
expect(typeof api.listScoutFindings).toBe('function');
|
||||
expect(typeof api.listSuggestions).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Page Module Export Tests ──────────────────────────────────────────
|
||||
|
||||
describe('Admin page module exports', () => {
|
||||
it('Dashboard is a function component', () => {
|
||||
expect(typeof Dashboard).toBe('function');
|
||||
});
|
||||
|
||||
it('Members is a function component', () => {
|
||||
expect(typeof Members).toBe('function');
|
||||
});
|
||||
|
||||
it('Capabilities is a function component', () => {
|
||||
expect(typeof Capabilities).toBe('function');
|
||||
});
|
||||
|
||||
it('Jobs is a function component', () => {
|
||||
expect(typeof Jobs).toBe('function');
|
||||
});
|
||||
|
||||
it('Audit is a function component', () => {
|
||||
expect(typeof Audit).toBe('function');
|
||||
});
|
||||
|
||||
it('TeamSettings is a function component', () => {
|
||||
expect(typeof TeamSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('Analytics is a function component', () => {
|
||||
expect(typeof Analytics).toBe('function');
|
||||
});
|
||||
|
||||
it('App root is a function component', () => {
|
||||
expect(typeof App).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rendering Tests ───────────────────────────────────────────────────
|
||||
|
||||
// Mock fetch globally for all rendering tests
|
||||
function mockFetch(responses: Record<string, unknown>) {
|
||||
// Sort patterns longest-first so more specific URLs match before shorter ones
|
||||
const sortedPatterns = Object.entries(responses).sort(
|
||||
([a], [b]) => b.length - a.length,
|
||||
);
|
||||
const mock = vi.fn(async (url: string, _opts?: RequestInit) => {
|
||||
// Match URL patterns (longest match first)
|
||||
for (const [pattern, data] of sortedPatterns) {
|
||||
if (url.includes(pattern)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Default: 404
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Not Found',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function mockPendingFetch() {
|
||||
const mock = vi.fn(() => new Promise(() => {}));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('App root component', () => {
|
||||
beforeEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the Waggle Admin title', () => {
|
||||
const { container } = render(React.createElement(App));
|
||||
expect(container.textContent).toContain('Waggle Admin');
|
||||
});
|
||||
|
||||
it('shows connect prompt when no token/slug entered', () => {
|
||||
render(React.createElement(App));
|
||||
expect(screen.getByText('Connect to a Team')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders all navigation items', () => {
|
||||
render(React.createElement(App));
|
||||
expect(screen.getByText('Dashboard')).toBeDefined();
|
||||
expect(screen.getByText('Analytics')).toBeDefined();
|
||||
expect(screen.getByText('Members')).toBeDefined();
|
||||
expect(screen.getByText('Capabilities')).toBeDefined();
|
||||
expect(screen.getByText('Jobs')).toBeDefined();
|
||||
expect(screen.getByText('Audit Log')).toBeDefined();
|
||||
expect(screen.getByText('Team Settings')).toBeDefined();
|
||||
});
|
||||
|
||||
it('exposes connection inputs with accessible labels and metadata', () => {
|
||||
render(React.createElement(App));
|
||||
|
||||
const teamSlug = screen.getByLabelText(/team slug/i) as HTMLInputElement;
|
||||
const authToken = screen.getByLabelText(/auth token/i) as HTMLInputElement;
|
||||
|
||||
expect(teamSlug.name).toBe('teamSlug');
|
||||
expect(teamSlug.autocomplete).toBe('organization');
|
||||
expect(authToken.name).toBe('authToken');
|
||||
expect(authToken.autocomplete).toBe('off');
|
||||
});
|
||||
|
||||
it('uses hash deep links and aria-current for admin navigation', () => {
|
||||
window.location.hash = '#members';
|
||||
render(React.createElement(App));
|
||||
|
||||
const members = screen.getByRole('button', { name: 'Members' });
|
||||
const capabilities = screen.getByRole('button', { name: 'Capabilities' });
|
||||
|
||||
expect(members.getAttribute('aria-current')).toBe('page');
|
||||
fireEvent.click(capabilities);
|
||||
expect(window.location.hash).toBe('#capabilities');
|
||||
expect(capabilities.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('ships a responsive admin shell stylesheet for narrow viewports', () => {
|
||||
const cssPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src/admin.css');
|
||||
const css = existsSync(cssPath) ? readFileSync(cssPath, 'utf8') : '';
|
||||
|
||||
expect(css).toContain('@media (max-width: 720px)');
|
||||
expect(css).toContain('.admin-shell');
|
||||
expect(css).toContain('.admin-sidebar');
|
||||
expect(css).toContain('.admin-main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading dashboard...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders team name and stat cards after data loads', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member' },
|
||||
],
|
||||
},
|
||||
'/api/teams/test/tasks': [
|
||||
{ id: 'task1', teamId: 't1', title: 'Fix bug', status: 'open', priority: 'high', createdBy: 'u1', createdAt: '2025-01-10', updatedAt: '2025-01-10' },
|
||||
{ id: 'task2', teamId: 't1', title: 'Write docs', status: 'completed', priority: 'medium', createdBy: 'u2', createdAt: '2025-01-11', updatedAt: '2025-01-11' },
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Team Dashboard')).toBeDefined();
|
||||
});
|
||||
|
||||
// Stat cards exist with correct labels
|
||||
expect(screen.getByText('Members')).toBeDefined();
|
||||
expect(screen.getByText('Active Tasks')).toBeDefined();
|
||||
expect(screen.getByText('Total Tasks')).toBeDefined();
|
||||
expect(screen.getByText('Team Slug')).toBeDefined();
|
||||
// Member count '2' appears in stat card (may also appear in task count area)
|
||||
expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Team members section
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
expect(screen.getByText('Bob')).toBeDefined();
|
||||
|
||||
// Recent tasks
|
||||
expect(screen.getByText('Fix bug')).toBeDefined();
|
||||
expect(screen.getByText('Write docs')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows error message when both API calls fail', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Internal Server Error',
|
||||
})));
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not connect to team server/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows auth guidance when both API calls reject the token', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Unauthorized',
|
||||
})));
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'bad-token', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Authentication failed. Check the admin auth token./)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Members page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders heading and invite form', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
expect(screen.getByText('Team Members')).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
});
|
||||
|
||||
// Owner should have a badge, not a select
|
||||
expect(screen.getByText('Owner')).toBeDefined();
|
||||
|
||||
// Invite form elements
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeDefined();
|
||||
expect(screen.getByText('Invite')).toBeDefined();
|
||||
});
|
||||
|
||||
it('labels invite fields and member role controls', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member', email: 'bob@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
await screen.findByText('Bob');
|
||||
|
||||
expect(screen.getByLabelText(/invite email/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/invite role/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/role for bob/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no members', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No members found/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('asks in-app before removing a member', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const fetchMock = mockFetch({
|
||||
'/api/teams/test/members/u2': {},
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member', email: 'bob@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
await screen.findByText('Bob');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove bob/i }));
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock.mock.calls.some(([url, opts]) => String(url).includes('/api/teams/test/members/u2') && opts?.method === 'DELETE')).toBe(false);
|
||||
expect(screen.getByRole('dialog').textContent).toMatch(/remove bob from the team/i);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove member/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.some(([url, opts]) => String(url).includes('/api/teams/test/members/u2') && opts?.method === 'DELETE')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jobs page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading jobs...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders job list after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/jobs': [
|
||||
{
|
||||
id: 'job-abc12345-long-id',
|
||||
teamId: 't1', userId: 'u1', jobType: 'agent_task',
|
||||
status: 'completed', input: {}, output: {},
|
||||
createdAt: '2025-01-10T12:00:00Z', completedAt: '2025-01-10T12:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 'job-def67890-long-id',
|
||||
teamId: 't1', userId: 'u1', jobType: 'research',
|
||||
status: 'running', input: {},
|
||||
createdAt: '2025-01-11T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('agent_task')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText('completed')).toBeDefined();
|
||||
expect(screen.getByText('running')).toBeDefined();
|
||||
expect(screen.getByText('research')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no jobs', async () => {
|
||||
mockFetch({ '/api/jobs': [] });
|
||||
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No jobs found')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading audit log...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders audit entries after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/admin/teams/test/audit': [
|
||||
{
|
||||
id: 'a1', userId: 'u1', agentName: 'waggle-1', actionType: 'tool_use',
|
||||
description: 'Executed shell command', requiresApproval: true, approved: true,
|
||||
createdAt: '2025-01-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'a2', userId: 'u1', agentName: 'waggle-1', actionType: 'memory_write',
|
||||
description: 'Stored project context', requiresApproval: false,
|
||||
createdAt: '2025-01-10T11:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tool_use')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText('memory_write')).toBeDefined();
|
||||
expect(screen.getByText('Approved')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no audit entries', async () => {
|
||||
mockFetch({ '/api/admin/teams/test/audit': [] });
|
||||
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No audit entries')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamSettings page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading team settings...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders team info after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 'team-uuid-123', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Test Team')).toBeDefined();
|
||||
});
|
||||
|
||||
// Slug display
|
||||
expect(screen.getByText('test')).toBeDefined();
|
||||
// Team ID display
|
||||
expect(screen.getByText('team-uuid-123')).toBeDefined();
|
||||
// Labels
|
||||
expect(screen.getByText('Team Name')).toBeDefined();
|
||||
expect(screen.getByText('Slug')).toBeDefined();
|
||||
expect(screen.getByText('Team ID')).toBeDefined();
|
||||
expect(screen.getByText('Created')).toBeDefined();
|
||||
});
|
||||
|
||||
it('labels the editable team name field', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 'team-uuid-123', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
const teamName = await screen.findByLabelText(/team name/i);
|
||||
expect((teamName as HTMLInputElement).name).toBe('teamName');
|
||||
});
|
||||
|
||||
it('save button is disabled when name unchanged', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Test Team')).toBeDefined();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save') as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capabilities page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders heading and tab bar', () => {
|
||||
mockPendingFetch();
|
||||
|
||||
render(React.createElement(Capabilities, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
expect(screen.getByText('Capabilities')).toBeDefined();
|
||||
expect(screen.getByText('Role Policies')).toBeDefined();
|
||||
expect(screen.getByText('Overrides')).toBeDefined();
|
||||
expect(screen.getByText('Requests')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading analytics...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders analytics cards after data loads', async () => {
|
||||
mockFetch({
|
||||
'/api/admin/teams/test/analytics': {
|
||||
activeUsers: { daily: 7, weekly: 19, monthly: 42 },
|
||||
tokenUsage: {
|
||||
total: 1500000,
|
||||
byUser: [
|
||||
{ userId: 'u1', name: 'Alice', tokens: 800000, cost: 4.50 },
|
||||
{ userId: 'u2', name: 'Bob', tokens: 700000, cost: 3.20 },
|
||||
],
|
||||
},
|
||||
topTools: [
|
||||
{ name: 'memory_search', invocations: 150 },
|
||||
{ name: 'shell_exec', invocations: 80 },
|
||||
],
|
||||
topCommands: [
|
||||
{ name: '/research', count: 45 },
|
||||
{ name: '/draft', count: 30 },
|
||||
],
|
||||
capabilityGaps: [
|
||||
{ tool: 'browser_navigate', requestCount: 9, suggestion: 'Install browser skill' },
|
||||
],
|
||||
performanceTrends: {
|
||||
correctionRate: 0.15,
|
||||
correctionTrend: -0.03,
|
||||
avgResponseTime: 8.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Usage Analytics')).toBeDefined();
|
||||
});
|
||||
|
||||
// Active users section
|
||||
expect(screen.getByText('Active Users')).toBeDefined();
|
||||
expect(screen.getByText('7')).toBeDefined(); // daily
|
||||
expect(screen.getByText('19')).toBeDefined(); // weekly
|
||||
expect(screen.getByText('42')).toBeDefined(); // monthly
|
||||
|
||||
// Token usage
|
||||
expect(screen.getByText('Token Usage')).toBeDefined();
|
||||
expect(screen.getByText('1.5M tokens')).toBeDefined();
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
expect(screen.getByText('Bob')).toBeDefined();
|
||||
|
||||
// Top tools
|
||||
expect(screen.getByText('Top Tools')).toBeDefined();
|
||||
expect(screen.getByText('memory_search')).toBeDefined();
|
||||
expect(screen.getByText('shell_exec')).toBeDefined();
|
||||
|
||||
// Top commands
|
||||
expect(screen.getByText('Top Commands')).toBeDefined();
|
||||
expect(screen.getByText('/research')).toBeDefined();
|
||||
expect(screen.getByText('/draft')).toBeDefined();
|
||||
|
||||
// Capability gaps
|
||||
expect(screen.getByText('Capability Gaps')).toBeDefined();
|
||||
expect(screen.getByText('browser_navigate')).toBeDefined();
|
||||
|
||||
// Performance trends
|
||||
expect(screen.getByText('Performance Trends')).toBeDefined();
|
||||
expect(screen.getByText('15.0%')).toBeDefined(); // correction rate
|
||||
expect(screen.getByText('8.5s')).toBeDefined(); // avg response time
|
||||
});
|
||||
|
||||
it('shows error when analytics API fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Server Error',
|
||||
})));
|
||||
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/API error: 500/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user