test(admin-web): Playwright E2E tests for diagnostics (Phase 4.4)

- Debug session CRUD: create, view, pause, resume, cancel
- Session filtering and search
- Logs viewing with level filtering and search
- Trace span viewing with expansion
- Screenshot gallery and lightbox navigation
- End-to-end workflow tests
- Error threshold and crash-triggered auto-session tests
This commit is contained in:
saravanakumardb1 2026-03-03 09:36:53 -08:00
parent a5d68decdb
commit 399f6f0bed
2 changed files with 727 additions and 0 deletions

View File

@ -0,0 +1,368 @@
import { test, expect } from '@playwright/test';
const ADMIN_EMAIL = 'admin@example.com';
const ADMIN_PASSWORD = 'Admin123!';
async function loginAsAdmin(page: any) {
await page.goto('/login');
await page.getByLabel('Email').fill(ADMIN_EMAIL);
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('**/dashboard', { timeout: 10000 });
}
test.describe('Diagnostics - Debug Sessions', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('navigates to diagnostics page', async ({ page }) => {
await page.click('text=Diagnostics');
await expect(page.getByText('Debug Sessions')).toBeVisible();
await expect(page.getByText('Create Session')).toBeVisible();
});
test('shows session list with filters', async ({ page }) => {
await page.click('text=Diagnostics');
// Check filter dropdowns exist
await expect(page.getByLabel('Status')).toBeVisible();
await expect(page.getByLabel('Collection Level')).toBeVisible();
// Check table headers
await expect(page.getByText('Session ID')).toBeVisible();
await expect(page.getByText('Status')).toBeVisible();
await expect(page.getByText('Target')).toBeVisible();
await expect(page.getByText('Logs')).toBeVisible();
await expect(page.getByText('Traces')).toBeVisible();
await expect(page.getByText('Created')).toBeVisible();
});
test('creates new debug session', async ({ page }) => {
await page.click('text=Diagnostics');
await page.click('text=Create Session');
// Should open modal
await expect(page.getByText('Create Debug Session')).toBeVisible();
// Fill form
await page.getByLabel('Target User ID').fill('user_test_123');
await page.getByLabel('Target Device ID').fill('device_test_456');
// Select collection level
await page.getByLabel('Collection Level').selectOption('debug');
// Enable capture options
await page.getByLabel('Capture Logs').check();
await page.getByLabel('Capture Network').check();
await page.getByLabel('Capture Screenshots').check();
// Set duration
await page.getByLabel('Max Duration (minutes)').fill('30');
// Submit
await page.click('text=Start Session');
// Should show success and close modal
await expect(page.getByText('Session created')).toBeVisible({ timeout: 5000 });
});
test('views session details', async ({ page }) => {
await page.click('text=Diagnostics');
// Click on first session
await page.locator('table tbody tr').first().click();
// Should show detail panel
await expect(page.getByText('Session Details')).toBeVisible();
await expect(page.getByText('Status')).toBeVisible();
await expect(page.getByText('Collection Level')).toBeVisible();
await expect(page.getByText('Logs')).toBeVisible();
await expect(page.getByText('Traces')).toBeVisible();
await expect(page.getByText('Screenshots')).toBeVisible();
});
test('pauses and resumes session', async ({ page }) => {
await page.click('text=Diagnostics');
// Find an active session
const activeRow = page.locator('tr:has-text("Active")').first();
if (await activeRow.isVisible().catch(() => false)) {
// Click pause
await activeRow.getByRole('button', { name: 'Pause' }).click();
await expect(page.getByText('Session paused')).toBeVisible();
// Resume
await page.locator('tr:has-text("Paused")').first().getByRole('button', { name: 'Resume' }).click();
await expect(page.getByText('Session resumed')).toBeVisible();
}
});
test('cancels session', async ({ page }) => {
await page.click('text=Diagnostics');
// Find a pending or active session
const sessionRow = page.locator('tr:has-text("Pending"), tr:has-text("Active")').first();
if (await sessionRow.isVisible().catch(() => false)) {
await sessionRow.getByRole('button', { name: 'Cancel' }).click();
// Confirm cancel
await page.getByLabel('Reason (optional)').fill('Test cancellation');
await page.click('text=Confirm Cancel');
await expect(page.getByText('Session cancelled')).toBeVisible();
}
});
test('filters sessions by status', async ({ page }) => {
await page.click('text=Diagnostics');
// Filter by Active
await page.getByLabel('Status').selectOption('active');
// Should only show active sessions
const rows = page.locator('table tbody tr');
const count = await rows.count();
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('td').nth(1)).toHaveText('Active');
}
});
test('searches sessions by user ID', async ({ page }) => {
await page.click('text=Diagnostics');
// Search
await page.getByPlaceholder('Search by user/device ID').fill('test_user');
await page.click('text=Search');
// Results should contain search term
const firstRow = page.locator('table tbody tr').first();
if (await firstRow.isVisible().catch(() => false)) {
await expect(firstRow).toContainText('test_user');
}
});
});
test.describe('Diagnostics - Logs & Traces', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.click('text=Diagnostics');
});
test('views session logs', async ({ page }) => {
// Click on a session with logs
const sessionWithLogs = page.locator('tr:has([data-logs="true"])').first();
if (await sessionWithLogs.isVisible().catch(() => false)) {
await sessionWithLogs.click();
// Click Logs tab
await page.click('text=Logs');
// Should show log entries
await expect(page.locator('[data-testid="log-entry"]').first()).toBeVisible();
// Check log level badges
await expect(page.getByText('INFO').or(page.getByText('ERROR')).or(page.getByText('DEBUG'))).toBeVisible();
}
});
test('filters logs by level', async ({ page }) => {
// Open a session and go to logs
const sessionWithLogs = page.locator('tr:has([data-logs="true"])').first();
if (await sessionWithLogs.isVisible().catch(() => false)) {
await sessionWithLogs.click();
await page.click('text=Logs');
// Filter by ERROR
await page.getByLabel('Log Level').selectOption('error');
// All visible logs should be ERROR level
const logs = page.locator('[data-testid="log-entry"]');
const count = await logs.count();
for (let i = 0; i < count; i++) {
await expect(logs.nth(i).locator('[data-level]')).toHaveAttribute('data-level', 'error');
}
}
});
test('searches logs by message', async ({ page }) => {
const sessionWithLogs = page.locator('tr:has([data-logs="true"])').first();
if (await sessionWithLogs.isVisible().catch(() => false)) {
await sessionWithLogs.click();
await page.click('text=Logs');
// Search
await page.getByPlaceholder('Search logs...').fill('error');
await page.click('text=Search');
// Results should contain search term
const firstLog = page.locator('[data-testid="log-entry"]').first();
if (await firstLog.isVisible().catch(() => false)) {
await expect(firstLog).toContainText('error');
}
}
});
test('views trace spans', async ({ page }) => {
const sessionWithTraces = page.locator('tr:has([data-traces="true"])').first();
if (await sessionWithTraces.isVisible().catch(() => false)) {
await sessionWithTraces.click();
await page.click('text=Traces');
// Should show trace tree
await expect(page.locator('[data-testid="trace-span"]').first()).toBeVisible();
// Should show span details
await expect(page.getByText('Duration')).toBeVisible();
await expect(page.getByText('Status')).toBeVisible();
}
});
test('expands trace span details', async ({ page }) => {
const sessionWithTraces = page.locator('tr:has([data-traces="true"])').first();
if (await sessionWithTraces.isVisible().catch(() => false)) {
await sessionWithTraces.click();
await page.click('text=Traces');
// Click to expand first span
const firstSpan = page.locator('[data-testid="trace-span"]').first();
await firstSpan.click();
// Should show span details
await expect(page.getByText('Attributes')).toBeVisible();
await expect(page.getByText('Events')).toBeVisible();
}
});
});
test.describe('Diagnostics - Screenshots', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.click('text=Diagnostics');
});
test('views screenshots gallery', async ({ page }) => {
const sessionWithScreenshots = page.locator('tr:has([data-screenshots="true"])').first();
if (await sessionWithScreenshots.isVisible().catch(() => false)) {
await sessionWithScreenshots.click();
await page.click('text=Screenshots');
// Should show screenshot thumbnails
await expect(page.locator('[data-testid="screenshot-thumb"]').first()).toBeVisible();
}
});
test('opens screenshot lightbox', async ({ page }) => {
const sessionWithScreenshots = page.locator('tr:has([data-screenshots="true"])').first();
if (await sessionWithScreenshots.isVisible().catch(() => false)) {
await sessionWithScreenshots.click();
await page.click('text=Screenshots');
// Click first screenshot
await page.locator('[data-testid="screenshot-thumb"]').first().click();
// Lightbox should open
await expect(page.locator('[data-testid="screenshot-lightbox"]')).toBeVisible();
await expect(page.locator('[data-testid="screenshot-full"]').first()).toBeVisible();
}
});
test('navigates between screenshots', async ({ page }) => {
const sessionWithScreenshots = page.locator('tr:has([data-screenshots="true"])').first();
if (await sessionWithScreenshots.isVisible().catch(() => false)) {
await sessionWithScreenshots.click();
await page.click('text=Screenshots');
// Open lightbox
await page.locator('[data-testid="screenshot-thumb"]').first().click();
// Navigate next
await page.click('text=Next');
await expect(page.getByText('2 /')).toBeVisible();
// Navigate prev
await page.click('text=Previous');
await expect(page.getByText('1 /')).toBeVisible();
}
});
});
test.describe('Diagnostics - End-to-End Flow', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('full workflow: create → capture → analyze', async ({ page }) => {
// 1. Create session
await page.click('text=Diagnostics');
await page.click('text=Create Session');
const targetUserId = `test_user_${Date.now()}`;
await page.getByLabel('Target User ID').fill(targetUserId);
await page.getByLabel('Collection Level').selectOption('debug');
await page.getByLabel('Capture Logs').check();
await page.click('text=Start Session');
await expect(page.getByText('Session created')).toBeVisible();
// 2. Verify session appears in list
await expect(page.locator('table tbody tr').first()).toContainText(targetUserId);
// 3. Open session details
await page.locator(`tr:has-text("${targetUserId}")`).first().click();
await expect(page.getByText('Session Details')).toBeVisible();
// 4. View logs (if any captured)
await page.click('text=Logs');
// 5. Pause session
await page.click('text=Actions');
await page.click('text=Pause');
await expect(page.getByText('Session paused')).toBeVisible();
// 6. Resume and complete
await page.click('text=Actions');
await page.click('text=Resume');
await page.click('text=Actions');
await page.click('text=Complete');
await expect(page.getByText('Session completed')).toBeVisible();
});
test('error threshold triggers auto-notification', async ({ page }) => {
// This would require backend simulation of error threshold
// For E2E, we verify the UI handles the notification
await page.click('text=Diagnostics');
// Look for notification about high error rate
const notification = page.locator('[data-testid="error-threshold-alert"]');
if (await notification.isVisible().catch(() => false)) {
await expect(notification).toContainText('High error rate detected');
// Should offer to create debug session
await notification.getByRole('button', { name: 'Start Debug Session' }).click();
await expect(page.getByText('Create Debug Session')).toBeVisible();
}
});
test('crash-triggered auto-session', async ({ page }) => {
// Look for auto-created session after crash
await page.click('text=Diagnostics');
// Filter to show auto-created sessions
await page.getByLabel('Source').selectOption('auto_crash');
// Should show auto-created sessions
const autoSession = page.locator('tr:has-text("Auto-created")').first();
if (await autoSession.isVisible().catch(() => false)) {
await expect(autoSession).toContainText('Crash detected');
// Open and verify it has crash data
await autoSession.click();
await page.click('text=Logs');
await expect(page.locator('[data-testid="log-entry"]')).toContainText('CRASH');
}
});
});

View File

@ -0,0 +1,359 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Plus, Search, RefreshCw, MoreHorizontal } from 'lucide-react';
import { createDiagnosticsClient, type DebugSession, type CreateSessionRequest } from '@/lib/diagnostics-client';
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
active: 'bg-green-500',
paused: 'bg-orange-500',
completed: 'bg-blue-500',
cancelled: 'bg-red-500',
};
export default function DebugSessionsPage() {
const [sessions, setSessions] = useState<DebugSession[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// Helper to get auth token from localStorage
const getAuthToken = async () => {
if (typeof window === 'undefined') return '';
return localStorage.getItem('admin_access_token') || '';
};
// New session form state
const [newSession, setNewSession] = useState<CreateSessionRequest>({
targetUserId: '',
targetDeviceId: '',
collectionLevel: 'standard' as const,
maxDurationMinutes: 60,
captureLogs: true,
captureNetwork: true,
captureScreenshots: false,
screenshotOnError: true,
});
const client = createDiagnosticsClient({
baseURL: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003',
productId: 'lysnrai',
getAuthToken,
});
const fetchSessions = async () => {
setLoading(true);
try {
const result = await client.querySessions({
productId: 'lysnrai',
status: statusFilter === 'all' ? undefined : statusFilter,
});
setSessions(result.sessions);
} catch (error) {
console.error('Failed to fetch sessions:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSessions();
// Auto-refresh every 5 seconds
const interval = setInterval(fetchSessions, 5000);
return () => clearInterval(interval);
}, [statusFilter]);
const handleCreateSession = async () => {
try {
await client.createSession(newSession);
setIsCreateModalOpen(false);
fetchSessions();
// Reset form
setNewSession({
targetUserId: '',
targetDeviceId: '',
collectionLevel: 'standard',
maxDurationMinutes: 60,
captureLogs: true,
captureNetwork: true,
captureScreenshots: false,
screenshotOnError: true,
});
} catch (error) {
console.error('Failed to create session:', error);
}
};
const filteredSessions = sessions.filter((session) => {
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
session.id.toLowerCase().includes(query) ||
session.targetUserId?.toLowerCase().includes(query) ||
session.targetDeviceId?.toLowerCase().includes(query)
);
}
return true;
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Debug Sessions</h1>
<p className="text-muted-foreground">
Remote diagnostics and debug tracing sessions
</p>
</div>
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Session
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Debug Session</DialogTitle>
<DialogDescription>
Start a remote debug session to collect logs, traces, and screenshots from a target device.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="userId">Target User ID</Label>
<Input
id="userId"
value={newSession.targetUserId}
onChange={(e) =>
setNewSession({ ...newSession, targetUserId: e.target.value })
}
placeholder="user_123"
/>
</div>
<div className="space-y-2">
<Label htmlFor="deviceId">Target Device ID</Label>
<Input
id="deviceId"
value={newSession.targetDeviceId}
onChange={(e) =>
setNewSession({ ...newSession, targetDeviceId: e.target.value })
}
placeholder="device_abc"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="collectionLevel">Collection Level</Label>
<Select
value={newSession.collectionLevel}
onValueChange={(value) =>
setNewSession({ ...newSession, collectionLevel: value as any })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
<SelectItem value="trace">Trace</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Duration (minutes)</Label>
<Input
id="duration"
type="number"
min={5}
max={1440}
value={newSession.maxDurationMinutes}
onChange={(e) =>
setNewSession({ ...newSession, maxDurationMinutes: parseInt(e.target.value) })
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Switch
id="captureLogs"
checked={newSession.captureLogs}
onCheckedChange={(checked) =>
setNewSession({ ...newSession, captureLogs: checked })
}
/>
<Label htmlFor="captureLogs">Capture Logs</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="captureNetwork"
checked={newSession.captureNetwork}
onCheckedChange={(checked) =>
setNewSession({ ...newSession, captureNetwork: checked })
}
/>
<Label htmlFor="captureNetwork">Capture Network</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="captureScreenshots"
checked={newSession.captureScreenshots}
onCheckedChange={(checked) =>
setNewSession({ ...newSession, captureScreenshots: checked })
}
/>
<Label htmlFor="captureScreenshots">Screenshots</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="screenshotOnError"
checked={newSession.screenshotOnError}
onCheckedChange={(checked) =>
setNewSession({ ...newSession, screenshotOnError: checked })
}
/>
<Label htmlFor="screenshotOnError">Screenshot on Error</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateModalOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateSession}>Start Session</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle>Sessions</CardTitle>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search sessions..."
className="pl-8 w-[250px]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={fetchSessions}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>User</TableHead>
<TableHead>Device</TableHead>
<TableHead>Level</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Started</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : filteredSessions.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No sessions found
</TableCell>
</TableRow>
) : (
filteredSessions.map((session) => (
<TableRow key={session.id}>
<TableCell className="font-mono text-sm">{session.id}</TableCell>
<TableCell>
<Badge
variant="secondary"
className={`${statusColors[session.status]} text-white`}
>
{session.status}
</Badge>
</TableCell>
<TableCell>{session.targetUserId || '-'}</TableCell>
<TableCell>{session.targetDeviceId || '-'}</TableCell>
<TableCell className="capitalize">{session.collectionLevel}</TableCell>
<TableCell>{session.maxDurationMinutes} min</TableCell>
<TableCell>
{session.startedAt
? new Date(session.startedAt).toLocaleString()
: '-'}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}