test(admin-web): Add Playwright E2E tests for broadcasts and surveys
- Broadcasts: list, create, target, clone, pause/resume, metrics, delete - Surveys: list, create with NPS/questions, conditional logic, activate/pause - Integration: navigation, targeting, incentives, export
This commit is contained in:
parent
55a1256d8b
commit
6e0b6c33c9
381
dashboards/admin-web/e2e/broadcasts-surveys.spec.ts
Normal file
381
dashboards/admin-web/e2e/broadcasts-surveys.spec.ts
Normal file
@ -0,0 +1,381 @@
|
||||
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();
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL('**/dashboard', { timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Broadcasts Admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('navigates to broadcasts page', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
await expect(page.getByText('Broadcasts')).toBeVisible();
|
||||
await expect(page.getByText('Create broadcast')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows broadcast list with filters', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
|
||||
// Check filter dropdowns exist
|
||||
await expect(page.getByLabel('Status')).toBeVisible();
|
||||
await expect(page.getByLabel('Channel')).toBeVisible();
|
||||
|
||||
// Check table headers
|
||||
await expect(page.getByText('Title')).toBeVisible();
|
||||
await expect(page.getByText('Status')).toBeVisible();
|
||||
await expect(page.getByText('Channel')).toBeVisible();
|
||||
await expect(page.getByText('Metrics')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates new broadcast draft', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
await page.click('text=Create broadcast');
|
||||
|
||||
// Should navigate to create page
|
||||
await expect(page).toHaveURL(/.*broadcasts\/new/);
|
||||
|
||||
// Fill basic info
|
||||
await page.getByLabel('Title').fill('Test Broadcast');
|
||||
await page.getByLabel('Body').fill('This is a test broadcast message');
|
||||
|
||||
// Select channel
|
||||
await page.getByLabel('Channel').selectOption('push');
|
||||
await page.getByLabel('Priority').selectOption('normal');
|
||||
await page.getByLabel('Style').selectOption('banner');
|
||||
|
||||
// Save as draft
|
||||
await page.click('text=Save Draft');
|
||||
|
||||
// Should show success and redirect
|
||||
await expect(page.getByText('Broadcast saved')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('creates broadcast with targeting', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
await page.click('text=Create broadcast');
|
||||
|
||||
// Basic info
|
||||
await page.getByLabel('Title').fill('Targeted Broadcast');
|
||||
await page.getByLabel('Body').fill('For pro users only');
|
||||
|
||||
// Go to targeting tab
|
||||
await page.click('text=Targeting');
|
||||
|
||||
// Select platforms
|
||||
await page.getByLabel('iOS').check();
|
||||
await page.getByLabel('Android').check();
|
||||
|
||||
// Select user segments
|
||||
await page.getByLabel('User Segments').selectOption(['pro']);
|
||||
|
||||
// Set percentage rollout
|
||||
await page.getByLabel('Percentage Rollout').fill('50');
|
||||
|
||||
// Go to schedule tab
|
||||
await page.click('text=Schedule');
|
||||
await page.getByLabel('Send immediately').check();
|
||||
|
||||
// Send
|
||||
await page.click('text=Send Broadcast');
|
||||
|
||||
// Confirm dialog
|
||||
await page.click('text=Confirm');
|
||||
|
||||
await expect(page.getByText('Broadcast sent')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('clones existing broadcast', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
|
||||
// Find first broadcast row and click clone
|
||||
const firstRow = page.locator('table tbody tr').first();
|
||||
await firstRow.getByRole('button', { name: 'Clone' }).click();
|
||||
|
||||
// Should be on new broadcast page with pre-filled data
|
||||
await expect(page).toHaveURL(/.*broadcasts\/new/);
|
||||
await expect(page.getByLabel('Title')).not.toHaveValue('');
|
||||
});
|
||||
|
||||
test('pauses and resumes broadcast', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
|
||||
// Find a sending broadcast
|
||||
const sendingRow = page.locator('tr:has-text("Sending")').first();
|
||||
|
||||
if (await sendingRow.isVisible().catch(() => false)) {
|
||||
await sendingRow.getByRole('button', { name: 'Pause' }).click();
|
||||
await expect(page.getByText('Broadcast paused')).toBeVisible();
|
||||
|
||||
// Resume
|
||||
await sendingRow.getByRole('button', { name: 'Resume' }).click();
|
||||
await expect(page.getByText('Broadcast resumed')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('views broadcast metrics', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
|
||||
// Click on first broadcast title
|
||||
await page.locator('table tbody tr td:first-child a').first().click();
|
||||
|
||||
// Should show detail page
|
||||
await expect(page.getByText('Metrics')).toBeVisible();
|
||||
await expect(page.getByText('Targeted')).toBeVisible();
|
||||
await expect(page.getByText('Sent')).toBeVisible();
|
||||
await expect(page.getByText('Delivered')).toBeVisible();
|
||||
await expect(page.getByText('Opened')).toBeVisible();
|
||||
await expect(page.getByText('Clicked')).toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes broadcast', async ({ page }) => {
|
||||
await page.click('text=Broadcasts');
|
||||
|
||||
// Find a draft broadcast to delete
|
||||
const draftRow = page.locator('tr:has-text("Draft")').first();
|
||||
|
||||
if (await draftRow.isVisible().catch(() => false)) {
|
||||
await draftRow.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Confirm delete
|
||||
await page.click('text=Confirm Delete');
|
||||
|
||||
await expect(page.getByText('Broadcast deleted')).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Surveys Admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('navigates to surveys page', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
await expect(page.getByText('Surveys')).toBeVisible();
|
||||
await expect(page.getByText('Create survey')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows survey list', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
|
||||
// Check table headers
|
||||
await expect(page.getByText('Title')).toBeVisible();
|
||||
await expect(page.getByText('Status')).toBeVisible();
|
||||
await expect(page.getByText('Responses')).toBeVisible();
|
||||
await expect(page.getByText('Completion Rate')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates new survey with NPS question', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
await page.click('text=Create survey');
|
||||
|
||||
// Should navigate to builder page
|
||||
await expect(page).toHaveURL(/.*surveys\/new/);
|
||||
|
||||
// Fill basic info
|
||||
await page.getByLabel('Survey Title').fill('NPS Survey Test');
|
||||
await page.getByLabel('Description').fill('How likely are you to recommend us?');
|
||||
|
||||
// Add NPS question
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'nps');
|
||||
await page.getByLabel('Question Text').fill('How likely are you to recommend us to a friend?');
|
||||
await page.click('text=Add');
|
||||
|
||||
// Add multiple choice question
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'single_choice');
|
||||
await page.getByLabel('Question Text').fill('What is your primary use case?');
|
||||
|
||||
// Add options
|
||||
await page.click('text=Add Option');
|
||||
await page.getByPlaceholder('Option text').fill('Voice dictation');
|
||||
await page.click('text=Add Option');
|
||||
await page.getByPlaceholder('Option text').nth(1).fill('Keyboard');
|
||||
|
||||
await page.click('text=Add');
|
||||
|
||||
// Save survey
|
||||
await page.click('text=Save Survey');
|
||||
|
||||
await expect(page.getByText('Survey saved')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('creates survey with conditional logic', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
await page.click('text=Create survey');
|
||||
|
||||
// Basic info
|
||||
await page.getByLabel('Survey Title').fill('Conditional Survey');
|
||||
|
||||
// Add first question (NPS)
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'nps');
|
||||
await page.getByLabel('Question Text').fill('Rate your experience');
|
||||
await page.click('text=Add');
|
||||
|
||||
// Add conditional follow-up question
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'text_long');
|
||||
await page.getByLabel('Question Text').fill('What can we improve?');
|
||||
|
||||
// Set conditional logic
|
||||
await page.click('text=Conditional Logic');
|
||||
await page.selectOption('select[name="conditionQuestion"]', 'q1');
|
||||
await page.selectOption('select[name="conditionOperator"]', 'not_equals');
|
||||
await page.fill('input[name="conditionValue"]', '9,10');
|
||||
|
||||
await page.click('text=Add');
|
||||
|
||||
// Save
|
||||
await page.click('text=Save Survey');
|
||||
await expect(page.getByText('Survey saved')).toBeVisible();
|
||||
});
|
||||
|
||||
test('activates and pauses survey', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
|
||||
// Find a draft survey
|
||||
const draftRow = page.locator('tr:has-text("Draft")').first();
|
||||
|
||||
if (await draftRow.isVisible().catch(() => false)) {
|
||||
// Click activate
|
||||
await draftRow.getByRole('button', { name: 'Activate' }).click();
|
||||
await expect(page.getByText('Survey activated')).toBeVisible();
|
||||
|
||||
// Find the now-active survey and pause it
|
||||
const activeRow = page.locator('tr:has-text("Active")').first();
|
||||
await activeRow.getByRole('button', { name: 'Pause' }).click();
|
||||
await expect(page.getByText('Survey paused')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('views survey metrics', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
|
||||
// Click on first survey
|
||||
await page.locator('table tbody tr td:first-child a').first().click();
|
||||
|
||||
// Should show detail page with tabs
|
||||
await expect(page.getByText('Overview')).toBeVisible();
|
||||
await expect(page.getByText('Questions')).toBeVisible();
|
||||
await expect(page.getByText('Responses')).toBeVisible();
|
||||
await expect(page.getByText('Analytics')).toBeVisible();
|
||||
|
||||
// Click Analytics tab
|
||||
await page.click('text=Analytics');
|
||||
await expect(page.getByText('Completion Rate')).toBeVisible();
|
||||
await expect(page.getByText('Average Time')).toBeVisible();
|
||||
});
|
||||
|
||||
test('exports survey responses', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
|
||||
// Click on first survey
|
||||
await page.locator('table tbody tr td:first-child a').first().click();
|
||||
|
||||
// Go to Responses tab
|
||||
await page.click('text=Responses');
|
||||
|
||||
// Click export
|
||||
await page.click('text=Export');
|
||||
|
||||
// Wait for download or success message
|
||||
await expect(page.getByText('Export started')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sets survey incentive', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
await page.click('text=Create survey');
|
||||
|
||||
// Basic info
|
||||
await page.getByLabel('Survey Title').fill('Incentivized Survey');
|
||||
|
||||
// Go to Settings tab
|
||||
await page.click('text=Settings');
|
||||
|
||||
// Enable incentive
|
||||
await page.getByLabel('Enable Incentive').check();
|
||||
await page.selectOption('select[name="incentiveType"]', 'pro_days');
|
||||
await page.getByLabel('Incentive Amount').fill('7');
|
||||
|
||||
// Add question
|
||||
await page.click('text=Questions');
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'nps');
|
||||
await page.getByLabel('Question Text').fill('How likely are you to recommend us?');
|
||||
await page.click('text=Add');
|
||||
|
||||
// Save
|
||||
await page.click('text=Save Survey');
|
||||
await expect(page.getByText('Survey saved')).toBeVisible();
|
||||
});
|
||||
|
||||
test('configures survey targeting', async ({ page }) => {
|
||||
await page.click('text=Surveys');
|
||||
await page.click('text=Create survey');
|
||||
|
||||
// Basic info
|
||||
await page.getByLabel('Survey Title').fill('Targeted Survey');
|
||||
|
||||
// Go to Targeting tab
|
||||
await page.click('text=Targeting');
|
||||
|
||||
// Select platforms
|
||||
await page.getByLabel('iOS').check();
|
||||
await page.getByLabel('Android').check();
|
||||
|
||||
// Select user segments
|
||||
await page.getByLabel('Active Users').check();
|
||||
await page.getByLabel('Pro Users').check();
|
||||
|
||||
// Set rollout
|
||||
await page.getByLabel('Percentage Rollout').fill('25');
|
||||
|
||||
// Add question
|
||||
await page.click('text=Questions');
|
||||
await page.click('text=Add Question');
|
||||
await page.selectOption('select[name="questionType"]', 'rating');
|
||||
await page.getByLabel('Question Text').fill('Rate the app');
|
||||
await page.click('text=Add');
|
||||
|
||||
// Save
|
||||
await page.click('text=Save Survey');
|
||||
await expect(page.getByText('Survey saved')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Broadcast & Survey Integration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('sidebar navigation shows both sections', async ({ page }) => {
|
||||
await expect(page.getByText('Broadcasts')).toBeVisible();
|
||||
await expect(page.getByText('Surveys')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can navigate between broadcasts and surveys', async ({ page }) => {
|
||||
// Go to broadcasts
|
||||
await page.click('text=Broadcasts');
|
||||
await expect(page.getByText('Create broadcast')).toBeVisible();
|
||||
|
||||
// Go to surveys
|
||||
await page.click('text=Surveys');
|
||||
await expect(page.getByText('Create survey')).toBeVisible();
|
||||
|
||||
// Back to broadcasts
|
||||
await page.click('text=Broadcasts');
|
||||
await expect(page.getByText('Create broadcast')).toBeVisible();
|
||||
});
|
||||
});
|
||||
32
packages/diagnostics-client/package.json
Normal file
32
packages/diagnostics-client/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@bytelyst/diagnostics-client",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript client for remote diagnostics and debug tracing",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/api-client": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
227
packages/diagnostics-client/src/__tests__/client.test.ts
Normal file
227
packages/diagnostics-client/src/__tests__/client.test.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
DiagnosticsClient,
|
||||
BreadcrumbTrail,
|
||||
NetworkInterceptor,
|
||||
} from '../index.js';
|
||||
|
||||
describe('DiagnosticsClient', () => {
|
||||
const mockConfig = {
|
||||
productId: 'test-app',
|
||||
anonymousInstallId: 'install_123',
|
||||
platform: 'web',
|
||||
channel: 'web_app',
|
||||
osFamily: 'macos',
|
||||
appVersion: '1.0.0',
|
||||
buildNumber: '100',
|
||||
releaseChannel: 'beta',
|
||||
serverUrl: 'https://api.test.com',
|
||||
pollIntervalMs: 100, // Fast polling for tests
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
DiagnosticsClient.reset();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock fetch to return empty session (no active debug session)
|
||||
fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
json: async () => null, // No active session
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
DiagnosticsClient.reset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('should create instance with getInstance', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
expect(client).toBeDefined();
|
||||
expect(DiagnosticsClient.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return same instance on subsequent calls', () => {
|
||||
const client1 = DiagnosticsClient.getInstance(mockConfig);
|
||||
const client2 = DiagnosticsClient.getInstance();
|
||||
expect(client1).toBe(client2);
|
||||
});
|
||||
|
||||
it('should throw if getInstance called without config first', () => {
|
||||
expect(() => DiagnosticsClient.getInstance()).toThrow(
|
||||
'must be initialized with config first'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset instance', () => {
|
||||
DiagnosticsClient.getInstance(mockConfig);
|
||||
expect(DiagnosticsClient.isInitialized()).toBe(true);
|
||||
DiagnosticsClient.reset();
|
||||
expect(DiagnosticsClient.isInitialized()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should start and stop', async () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
await client.start();
|
||||
// After start, state should be polling (no active session from mock)
|
||||
expect(client.getState().type).toBe('polling');
|
||||
client.stop();
|
||||
expect(client.getState().type).toBe('idle');
|
||||
});
|
||||
|
||||
it('should warn if started twice', async () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
await client.start();
|
||||
// First clear any calls from the first start
|
||||
mockConfig.logger.warn.mockClear();
|
||||
// Second start should warn
|
||||
await client.start();
|
||||
expect(mockConfig.logger.warn).toHaveBeenCalledWith(
|
||||
'[diagnostics] Already started'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session state', () => {
|
||||
it('should report no active session initially', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
expect(client.isSessionActive()).toBe(false);
|
||||
expect(client.getCurrentSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('should record log entries', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
client.log('info', 'Test message', { foo: 'bar' });
|
||||
// Logs are buffered, no immediate assertion
|
||||
expect(client.getBreadcrumbs().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should add breadcrumb on log', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
client.log('warn', 'Warning message');
|
||||
const crumbs = client.getBreadcrumbs();
|
||||
expect(crumbs.some(c => c.message.includes('Warning'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracing', () => {
|
||||
it('should trace successful operation', async () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
const result = await client.trace('test-op', () => 'success');
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('should trace async operation', async () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
const result = await client.trace('async-op', async () => {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should propagate errors', async () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
await expect(
|
||||
client.trace('failing-op', () => {
|
||||
throw new Error('test error');
|
||||
})
|
||||
).rejects.toThrow('test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('breadcrumbs', () => {
|
||||
it('should add breadcrumbs', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
client.breadcrumb('navigation', 'Page loaded', { path: '/' });
|
||||
const crumbs = client.getBreadcrumbs();
|
||||
expect(crumbs.length).toBe(1);
|
||||
expect(crumbs[0].category).toBe('navigation');
|
||||
expect(crumbs[0].message).toBe('Page loaded');
|
||||
});
|
||||
|
||||
it('should include data in breadcrumbs', () => {
|
||||
const client = DiagnosticsClient.getInstance(mockConfig);
|
||||
client.breadcrumb('user', 'Clicked', { button: 'submit' });
|
||||
const crumbs = client.getBreadcrumbs();
|
||||
expect(crumbs[0].data).toEqual({ button: 'submit' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BreadcrumbTrail', () => {
|
||||
it('should add breadcrumbs', () => {
|
||||
const trail = new BreadcrumbTrail();
|
||||
trail.add('test', 'message');
|
||||
expect(trail.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('should evict oldest when over limit', () => {
|
||||
const trail = new BreadcrumbTrail({ maxSize: 3 });
|
||||
trail.add('a', '1');
|
||||
trail.add('b', '2');
|
||||
trail.add('c', '3');
|
||||
trail.add('d', '4'); // Should evict 'a'
|
||||
expect(trail.size()).toBe(3);
|
||||
const all = trail.getAll();
|
||||
expect(all[0].category).toBe('b');
|
||||
});
|
||||
|
||||
it('should get last N breadcrumbs', () => {
|
||||
const trail = new BreadcrumbTrail();
|
||||
trail.add('a', '1');
|
||||
trail.add('b', '2');
|
||||
trail.add('c', '3');
|
||||
const last2 = trail.getLast(2);
|
||||
expect(last2.length).toBe(2);
|
||||
expect(last2[0].category).toBe('b');
|
||||
});
|
||||
|
||||
it('should get most recent', () => {
|
||||
const trail = new BreadcrumbTrail();
|
||||
trail.add('a', '1');
|
||||
trail.add('b', '2');
|
||||
const recent = trail.getMostRecent();
|
||||
expect(recent?.category).toBe('b');
|
||||
});
|
||||
|
||||
it('should clear all', () => {
|
||||
const trail = new BreadcrumbTrail();
|
||||
trail.add('a', '1');
|
||||
trail.clear();
|
||||
expect(trail.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NetworkInterceptor', () => {
|
||||
it('should start and stop', () => {
|
||||
const onRequest = vi.fn();
|
||||
const interceptor = new NetworkInterceptor(onRequest);
|
||||
interceptor.start();
|
||||
expect(interceptor.isRunning()).toBe(true);
|
||||
interceptor.stop();
|
||||
expect(interceptor.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not capture when stopped', () => {
|
||||
const onRequest = vi.fn();
|
||||
const interceptor = new NetworkInterceptor(onRequest);
|
||||
expect(interceptor.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
78
packages/diagnostics-client/src/breadcrumbs.ts
Normal file
78
packages/diagnostics-client/src/breadcrumbs.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Breadcrumb trail — ring buffer for timeline navigation
|
||||
*
|
||||
* @module breadcrumbs
|
||||
*/
|
||||
|
||||
import type { Breadcrumb } from './types.js';
|
||||
|
||||
export interface BreadcrumbTrailOptions {
|
||||
/** Maximum number of breadcrumbs to keep (default: 100) */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring buffer for breadcrumbs with fixed max size
|
||||
*/
|
||||
export class BreadcrumbTrail {
|
||||
private breadcrumbs: Breadcrumb[] = [];
|
||||
private maxSize: number;
|
||||
|
||||
constructor(options: BreadcrumbTrailOptions = {}) {
|
||||
this.maxSize = options.maxSize ?? 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a breadcrumb to the trail
|
||||
*/
|
||||
add(category: string, message: string, data?: Record<string, unknown>): void {
|
||||
const breadcrumb: Breadcrumb = {
|
||||
timestamp: new Date().toISOString(),
|
||||
category,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
|
||||
this.breadcrumbs.push(breadcrumb);
|
||||
|
||||
// Evict oldest if over limit
|
||||
if (this.breadcrumbs.length > this.maxSize) {
|
||||
this.breadcrumbs.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all breadcrumbs (oldest first)
|
||||
*/
|
||||
getAll(): Breadcrumb[] {
|
||||
return [...this.breadcrumbs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N breadcrumbs
|
||||
*/
|
||||
getLast(n: number): Breadcrumb[] {
|
||||
return this.breadcrumbs.slice(-n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most recent breadcrumb
|
||||
*/
|
||||
getMostRecent(): Breadcrumb | null {
|
||||
return this.breadcrumbs[this.breadcrumbs.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all breadcrumbs
|
||||
*/
|
||||
clear(): void {
|
||||
this.breadcrumbs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current size
|
||||
*/
|
||||
size(): number {
|
||||
return this.breadcrumbs.length;
|
||||
}
|
||||
}
|
||||
515
packages/diagnostics-client/src/client.ts
Normal file
515
packages/diagnostics-client/src/client.ts
Normal file
@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Main DiagnosticsClient — singleton for remote diagnostics collection
|
||||
*
|
||||
* @module client
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiagnosticsConfig,
|
||||
DiagnosticsSession,
|
||||
ClientState,
|
||||
LogLevel,
|
||||
TraceSpan,
|
||||
LogEntry,
|
||||
Breadcrumb,
|
||||
NetworkRequest,
|
||||
IngestBatch,
|
||||
DeviceState,
|
||||
} from './types.js';
|
||||
import { BreadcrumbTrail } from './breadcrumbs.js';
|
||||
import { NetworkInterceptor } from './network.js';
|
||||
import { collectDeviceState } from './device.js';
|
||||
|
||||
export interface DiagnosticsClientOptions extends DiagnosticsConfig {
|
||||
/** Custom logger */
|
||||
logger?: {
|
||||
debug: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
info: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
error: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics client for remote debug session collection
|
||||
*/
|
||||
export class DiagnosticsClient {
|
||||
private static instance: DiagnosticsClient | null = null;
|
||||
|
||||
private config: DiagnosticsClientOptions & {
|
||||
pollIntervalMs: number;
|
||||
maxBreadcrumbs: number;
|
||||
captureConsole: boolean;
|
||||
captureErrors: boolean;
|
||||
captureNetwork: boolean;
|
||||
networkExcludePatterns: RegExp[];
|
||||
logger: NonNullable<DiagnosticsClientOptions['logger']>;
|
||||
};
|
||||
private state: ClientState = { type: 'idle' };
|
||||
private breadcrumbs: BreadcrumbTrail;
|
||||
private networkInterceptor: NetworkInterceptor | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private logBuffer: LogEntry[] = [];
|
||||
private traceBuffer: TraceSpan[] = [];
|
||||
private networkBuffer: NetworkRequest[] = [];
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastEtag: string | null = null;
|
||||
|
||||
private constructor(config: DiagnosticsClientOptions) {
|
||||
this.config = {
|
||||
...config,
|
||||
pollIntervalMs: config.pollIntervalMs ?? 5000,
|
||||
maxBreadcrumbs: config.maxBreadcrumbs ?? 100,
|
||||
captureConsole: config.captureConsole ?? true,
|
||||
captureErrors: config.captureErrors ?? true,
|
||||
captureNetwork: config.captureNetwork ?? true,
|
||||
networkExcludePatterns: config.networkExcludePatterns ?? [],
|
||||
logger: config.logger ?? {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
this.breadcrumbs = new BreadcrumbTrail({
|
||||
maxSize: this.config.maxBreadcrumbs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient {
|
||||
if (!DiagnosticsClient.instance) {
|
||||
if (!config) {
|
||||
throw new Error('DiagnosticsClient must be initialized with config first');
|
||||
}
|
||||
DiagnosticsClient.instance = new DiagnosticsClient(config);
|
||||
}
|
||||
return DiagnosticsClient.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is initialized
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return DiagnosticsClient.instance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton (for testing)
|
||||
*/
|
||||
static reset(): void {
|
||||
DiagnosticsClient.instance?.stop();
|
||||
DiagnosticsClient.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for active debug sessions
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.state.type === 'polling' || this.state.type === 'active') {
|
||||
this.config.logger.warn('[diagnostics] Already started');
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = { type: 'polling', session: null };
|
||||
this.config.logger.info('[diagnostics] Starting diagnostics client');
|
||||
|
||||
// Initial poll
|
||||
await this.pollForSession();
|
||||
|
||||
// Start polling timer
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.pollForSession().catch(err => {
|
||||
this.config.logger.error('[diagnostics] Poll error', { error: err.message });
|
||||
});
|
||||
}, this.config.pollIntervalMs);
|
||||
|
||||
// Start auto-flush timer (every 30 seconds)
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.flush().catch(err => {
|
||||
this.config.logger.error('[diagnostics] Flush error', { error: err.message });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
// Setup auto-capture if configured
|
||||
if (this.config.captureNetwork) {
|
||||
this.setupNetworkCapture();
|
||||
}
|
||||
|
||||
if (this.config.captureConsole) {
|
||||
this.setupConsoleCapture();
|
||||
}
|
||||
|
||||
if (this.config.captureErrors) {
|
||||
this.setupErrorCapture();
|
||||
}
|
||||
|
||||
this.breadcrumbs.add('diagnostics', 'Client started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling and cleanup
|
||||
*/
|
||||
stop(): void {
|
||||
this.config.logger.info('[diagnostics] Stopping diagnostics client');
|
||||
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
this.networkInterceptor?.stop();
|
||||
this.networkInterceptor = null;
|
||||
|
||||
// Final flush
|
||||
this.flush().catch(() => {});
|
||||
|
||||
this.state = { type: 'idle' };
|
||||
this.breadcrumbs.add('diagnostics', 'Client stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a debug session is currently active
|
||||
*/
|
||||
isSessionActive(): boolean {
|
||||
return this.state.type === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session if active
|
||||
*/
|
||||
getCurrentSession(): DiagnosticsSession | null {
|
||||
return this.state.type === 'active' || this.state.type === 'polling'
|
||||
? (this.state as { session: DiagnosticsSession | null }).session
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current client state
|
||||
*/
|
||||
getState(): ClientState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a log entry
|
||||
*/
|
||||
log(level: LogLevel, message: string, context: Record<string, unknown> = {}): void {
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
module: context.module as string ?? 'unknown',
|
||||
context,
|
||||
correlationId: context.correlationId as string,
|
||||
};
|
||||
|
||||
this.logBuffer.push(entry);
|
||||
this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level });
|
||||
|
||||
// Auto-flush on fatal
|
||||
if (level === 'fatal') {
|
||||
this.flush().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a trace span (auto-instrumented)
|
||||
*/
|
||||
async trace<T>(name: string, operation: () => Promise<T>): Promise<T>;
|
||||
async trace<T>(name: string, operation: () => T): Promise<T>;
|
||||
async trace<T>(name: string, operation: () => T | Promise<T>): Promise<T> {
|
||||
const span: TraceSpan = {
|
||||
spanId: this.generateId(),
|
||||
name,
|
||||
kind: 'internal',
|
||||
startTime: new Date().toISOString(),
|
||||
attributes: {},
|
||||
status: 'unset',
|
||||
};
|
||||
|
||||
this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId });
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
span.endTime = new Date().toISOString();
|
||||
span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime();
|
||||
span.status = 'ok';
|
||||
this.traceBuffer.push(span);
|
||||
this.breadcrumbs.add('trace', `Completed: ${name}`, { spanId: span.spanId, durationMs: span.durationMs });
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.endTime = new Date().toISOString();
|
||||
span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime();
|
||||
span.status = 'error';
|
||||
span.statusMessage = error instanceof Error ? error.message : String(error);
|
||||
this.traceBuffer.push(span);
|
||||
this.breadcrumbs.add('trace', `Failed: ${name}`, { spanId: span.spanId, error: span.statusMessage });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a manual breadcrumb
|
||||
*/
|
||||
breadcrumb(category: string, message: string, data?: Record<string, unknown>): void {
|
||||
this.breadcrumbs.add(category, message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all breadcrumbs
|
||||
*/
|
||||
getBreadcrumbs(): Breadcrumb[] {
|
||||
return this.breadcrumbs.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and return device state
|
||||
*/
|
||||
collectDeviceState(): DeviceState {
|
||||
return collectDeviceState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll server for active session config
|
||||
*/
|
||||
private async pollForSession(): Promise<void> {
|
||||
try {
|
||||
const url = new URL('/api/diagnostics/config', this.config.serverUrl);
|
||||
url.searchParams.set('productId', this.config.productId);
|
||||
url.searchParams.set('installId', this.config.anonymousInstallId);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (this.lastEtag) {
|
||||
headers['If-None-Match'] = this.lastEtag;
|
||||
}
|
||||
|
||||
const token = await this.getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 304) {
|
||||
// No change
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Store ETag for caching
|
||||
const etag = response.headers.get('ETag');
|
||||
if (etag) {
|
||||
this.lastEtag = etag;
|
||||
}
|
||||
|
||||
const session: DiagnosticsSession | null = await response.json();
|
||||
|
||||
// Update state
|
||||
if (session && session.status === 'active') {
|
||||
if (this.state.type !== 'active') {
|
||||
this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id });
|
||||
this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id });
|
||||
}
|
||||
this.state = { type: 'active', session };
|
||||
} else {
|
||||
if (this.state.type === 'active') {
|
||||
this.config.logger.info('[diagnostics] Session ended');
|
||||
this.breadcrumbs.add('diagnostics', 'Session ended');
|
||||
}
|
||||
this.state = { type: 'polling', session: null };
|
||||
}
|
||||
} catch (error) {
|
||||
this.config.logger.error('[diagnostics] Failed to poll for session', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.state = { type: 'error', error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered data to server
|
||||
*/
|
||||
private async flush(): Promise<void> {
|
||||
const session = this.getCurrentSession();
|
||||
if (!session) {
|
||||
// No active session, clear buffers
|
||||
this.logBuffer = [];
|
||||
this.traceBuffer = [];
|
||||
this.networkBuffer = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Build batch
|
||||
const batch: IngestBatch = {
|
||||
sessionId: session.id,
|
||||
};
|
||||
|
||||
if (this.logBuffer.length > 0) {
|
||||
batch.logs = this.logBuffer.splice(0, 50); // Max 50 per batch
|
||||
}
|
||||
|
||||
if (this.traceBuffer.length > 0) {
|
||||
batch.traces = this.traceBuffer.splice(0, 50);
|
||||
}
|
||||
|
||||
if (this.networkBuffer.length > 0) {
|
||||
batch.network = this.networkBuffer.splice(0, 50);
|
||||
}
|
||||
|
||||
// Add breadcrumbs
|
||||
const crumbs = this.breadcrumbs.getAll();
|
||||
if (crumbs.length > 0) {
|
||||
batch.breadcrumbs = [...crumbs];
|
||||
this.breadcrumbs.clear();
|
||||
}
|
||||
|
||||
// Skip if nothing to send
|
||||
if (!batch.logs && !batch.traces && !batch.network && !batch.breadcrumbs) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL('/api/diagnostics/ingest', this.config.serverUrl);
|
||||
const token = await this.getAuthToken();
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.config.logger.debug('[diagnostics] Flushed batch', {
|
||||
logs: batch.logs?.length ?? 0,
|
||||
traces: batch.traces?.length ?? 0,
|
||||
network: batch.network?.length ?? 0,
|
||||
});
|
||||
} catch (error) {
|
||||
this.config.logger.error('[diagnostics] Failed to flush batch', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Put items back in buffers for retry
|
||||
if (batch.logs) this.logBuffer.unshift(...batch.logs);
|
||||
if (batch.traces) this.traceBuffer.unshift(...batch.traces);
|
||||
if (batch.network) this.networkBuffer.unshift(...batch.network);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup network capture
|
||||
*/
|
||||
private setupNetworkCapture(): void {
|
||||
this.networkInterceptor = new NetworkInterceptor(
|
||||
(request) => {
|
||||
this.networkBuffer.push(request);
|
||||
},
|
||||
{
|
||||
excludePatterns: this.config.networkExcludePatterns,
|
||||
}
|
||||
);
|
||||
this.networkInterceptor.start();
|
||||
this.breadcrumbs.add('diagnostics', 'Network capture enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup console capture
|
||||
*/
|
||||
private setupConsoleCapture(): void {
|
||||
const originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
|
||||
const capture = (level: LogLevel, args: unknown[]) => {
|
||||
const message = args.map(a =>
|
||||
typeof a === 'object' ? JSON.stringify(a) : String(a)
|
||||
).join(' ');
|
||||
this.log(level, message, { module: 'console', source: 'captured' });
|
||||
};
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
capture('debug', args);
|
||||
originalConsole.log(...args);
|
||||
};
|
||||
console.info = (...args: unknown[]) => {
|
||||
capture('info', args);
|
||||
originalConsole.info(...args);
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
capture('warn', args);
|
||||
originalConsole.warn(...args);
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
capture('error', args);
|
||||
originalConsole.error(...args);
|
||||
};
|
||||
|
||||
this.breadcrumbs.add('diagnostics', 'Console capture enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup error capture
|
||||
*/
|
||||
private setupErrorCapture(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handler = (event: ErrorEvent) => {
|
||||
this.log('error', event.message, {
|
||||
module: 'window.onerror',
|
||||
source: 'captured',
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
error: event.error?.stack,
|
||||
});
|
||||
this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`);
|
||||
};
|
||||
|
||||
window.addEventListener('error', handler);
|
||||
this.breadcrumbs.add('diagnostics', 'Error capture enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth token
|
||||
*/
|
||||
private async getAuthToken(): Promise<string | null> {
|
||||
if (!this.config.getAuthToken) return null;
|
||||
try {
|
||||
const token = await this.config.getAuthToken();
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
}
|
||||
90
packages/diagnostics-client/src/device.ts
Normal file
90
packages/diagnostics-client/src/device.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Device state collector — memory, battery, storage, network
|
||||
*
|
||||
* @module device
|
||||
*/
|
||||
|
||||
import type { DeviceState } from './types.js';
|
||||
|
||||
/**
|
||||
* Collect current device state
|
||||
* Best-effort: some APIs may not be available in all environments
|
||||
*/
|
||||
export function collectDeviceState(): DeviceState {
|
||||
const state: DeviceState = {
|
||||
isOnline: navigator.onLine ?? true,
|
||||
};
|
||||
|
||||
// Network type (experimental API)
|
||||
const connection = (navigator as Navigator & { connection?: NetworkInformation }).connection;
|
||||
if (connection) {
|
||||
state.networkType = connection.effectiveType ?? 'unknown';
|
||||
}
|
||||
|
||||
// Battery API (experimental, not widely supported)
|
||||
// Note: Battery API is deprecated but still useful for diagnostics
|
||||
const battery = (navigator as Navigator & { getBattery?: () => Promise<BatteryManager> }).getBattery;
|
||||
if (battery) {
|
||||
// We'll return a promise, but sync API can't wait
|
||||
// Store last known value if available
|
||||
}
|
||||
|
||||
// Memory (Chrome-only experimental)
|
||||
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
|
||||
if (memory) {
|
||||
state.memoryMB = Math.round(memory.usedJSHeapSize / 1024 / 1024);
|
||||
}
|
||||
|
||||
// Storage (async, but we'll fire-and-forget)
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
navigator.storage.estimate().then(estimate => {
|
||||
if (estimate.usage !== undefined) {
|
||||
state.storageMB = Math.round(estimate.usage / 1024 / 1024);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to online/offline events
|
||||
*/
|
||||
export function subscribeToConnectivity(
|
||||
callback: (isOnline: boolean) => void
|
||||
): () => void {
|
||||
const handleOnline = () => callback(true);
|
||||
const handleOffline = () => callback(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Network Information interface (experimental)
|
||||
*/
|
||||
interface NetworkInformation {
|
||||
effectiveType?: string;
|
||||
downlink?: number;
|
||||
rtt?: number;
|
||||
saveData?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Battery Manager interface (experimental)
|
||||
*/
|
||||
interface BatteryManager {
|
||||
charging: boolean;
|
||||
level: number;
|
||||
chargingTime: number;
|
||||
dischargingTime: number;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
}
|
||||
71
packages/diagnostics-client/src/index.ts
Normal file
71
packages/diagnostics-client/src/index.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @bytelyst/diagnostics-client
|
||||
*
|
||||
* Remote diagnostics and debug tracing client for the ByteLyst ecosystem.
|
||||
* Provides polling, logging, tracing, network capture, and breadcrumbs.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { DiagnosticsClient } from '@bytelyst/diagnostics-client';
|
||||
*
|
||||
* const client = DiagnosticsClient.getInstance({
|
||||
* productId: 'myapp',
|
||||
* anonymousInstallId: 'install_123',
|
||||
* platform: 'web',
|
||||
* channel: 'web_app',
|
||||
* osFamily: 'macos',
|
||||
* appVersion: '1.0.0',
|
||||
* buildNumber: '100',
|
||||
* releaseChannel: 'stable',
|
||||
* serverUrl: 'https://api.bytelyst.com',
|
||||
* });
|
||||
*
|
||||
* await client.start();
|
||||
*
|
||||
* // Auto-instrumented trace
|
||||
* const result = await client.trace('fetchUser', async () => {
|
||||
* return await fetch('/api/user').then(r => r.json());
|
||||
* });
|
||||
*
|
||||
* // Manual breadcrumb
|
||||
* client.breadcrumb('user', 'Clicked submit button', { formId: 'signup' });
|
||||
*
|
||||
* // Manual log
|
||||
* client.log('info', 'User signed up', { userId: '123' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
DiagnosticsClient,
|
||||
type DiagnosticsClientOptions,
|
||||
} from './client.js';
|
||||
|
||||
export {
|
||||
BreadcrumbTrail,
|
||||
type BreadcrumbTrailOptions,
|
||||
} from './breadcrumbs.js';
|
||||
|
||||
export {
|
||||
NetworkInterceptor,
|
||||
type NetworkInterceptorOptions,
|
||||
} from './network.js';
|
||||
|
||||
export {
|
||||
collectDeviceState,
|
||||
subscribeToConnectivity,
|
||||
} from './device.js';
|
||||
|
||||
export type {
|
||||
LogLevel,
|
||||
SessionStatus,
|
||||
CollectionLevel,
|
||||
DiagnosticsSession,
|
||||
TraceSpan,
|
||||
LogEntry,
|
||||
Breadcrumb,
|
||||
NetworkRequest,
|
||||
DeviceState,
|
||||
DiagnosticsConfig,
|
||||
ClientState,
|
||||
IngestBatch,
|
||||
} from './types.js';
|
||||
210
packages/diagnostics-client/src/network.ts
Normal file
210
packages/diagnostics-client/src/network.ts
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Network interceptor — capture HTTP requests/responses
|
||||
*
|
||||
* @module network
|
||||
*/
|
||||
|
||||
import type { NetworkRequest } from './types.js';
|
||||
|
||||
export interface NetworkInterceptorOptions {
|
||||
/** URL patterns to include (default: all) */
|
||||
includePatterns?: RegExp[];
|
||||
/** URL patterns to exclude */
|
||||
excludePatterns?: RegExp[];
|
||||
/** Max request body size to capture (default: 100KB) */
|
||||
maxBodySize?: number;
|
||||
/** Whether to capture request headers (default: true) */
|
||||
captureRequestHeaders?: boolean;
|
||||
/** Whether to capture response headers (default: true) */
|
||||
captureResponseHeaders?: boolean;
|
||||
/** Sanitize header values matching these patterns */
|
||||
sensitiveHeaderPatterns?: RegExp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interceptor for capturing network requests
|
||||
*/
|
||||
export class NetworkInterceptor {
|
||||
private options: Required<NetworkInterceptorOptions>;
|
||||
private originalFetch: typeof fetch;
|
||||
private isActive = false;
|
||||
private pendingRequests = new Map<string, NetworkRequest>();
|
||||
private onRequest: (request: NetworkRequest) => void;
|
||||
|
||||
constructor(
|
||||
onRequest: (request: NetworkRequest) => void,
|
||||
options: NetworkInterceptorOptions = {}
|
||||
) {
|
||||
this.onRequest = onRequest;
|
||||
this.options = {
|
||||
includePatterns: options.includePatterns ?? [],
|
||||
excludePatterns: options.excludePatterns ?? [],
|
||||
maxBodySize: options.maxBodySize ?? 100 * 1024,
|
||||
captureRequestHeaders: options.captureRequestHeaders ?? true,
|
||||
captureResponseHeaders: options.captureResponseHeaders ?? true,
|
||||
sensitiveHeaderPatterns: options.sensitiveHeaderPatterns ?? [
|
||||
/authorization/i,
|
||||
/cookie/i,
|
||||
/token/i,
|
||||
/api-key/i,
|
||||
],
|
||||
};
|
||||
this.originalFetch = globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start intercepting fetch calls
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isActive) return;
|
||||
this.isActive = true;
|
||||
|
||||
globalThis.fetch = this.interceptedFetch.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop intercepting fetch calls
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isActive) return;
|
||||
this.isActive = false;
|
||||
|
||||
globalThis.fetch = this.originalFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL should be captured
|
||||
*/
|
||||
private shouldCapture(url: string): boolean {
|
||||
// Check excludes first
|
||||
for (const pattern of this.options.excludePatterns) {
|
||||
if (pattern.test(url)) return false;
|
||||
}
|
||||
|
||||
// If includes specified, must match one
|
||||
if (this.options.includePatterns.length > 0) {
|
||||
for (const pattern of this.options.includePatterns) {
|
||||
if (pattern.test(url)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize headers
|
||||
*/
|
||||
private sanitizeHeaders(
|
||||
headers: HeadersInit | undefined
|
||||
): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
const headerEntries = headers instanceof Headers
|
||||
? Array.from(headers.entries())
|
||||
: typeof headers === 'object' && headers !== null
|
||||
? Object.entries(headers)
|
||||
: [];
|
||||
|
||||
for (const [key, value] of headerEntries) {
|
||||
const isSensitive = this.options.sensitiveHeaderPatterns.some(p =>
|
||||
p.test(key)
|
||||
);
|
||||
sanitized[key] = isSensitive ? '[REDACTED]' : value;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate request ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepted fetch implementation
|
||||
*/
|
||||
private async interceptedFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
const url = input.toString();
|
||||
const shouldCapture = this.shouldCapture(url);
|
||||
|
||||
const requestId = this.generateId();
|
||||
const startTime = new Date().toISOString();
|
||||
|
||||
// Create request record if capturing
|
||||
if (shouldCapture) {
|
||||
const request: NetworkRequest = {
|
||||
id: requestId,
|
||||
url: url.slice(0, 2048), // Limit URL length
|
||||
method: (init?.method ?? 'GET').toUpperCase(),
|
||||
requestHeaders: this.options.captureRequestHeaders
|
||||
? this.sanitizeHeaders(init?.headers)
|
||||
: {},
|
||||
startTime,
|
||||
};
|
||||
|
||||
// Capture request body if present and not too large
|
||||
if (init?.body && typeof init.body === 'string') {
|
||||
if (init.body.length <= this.options.maxBodySize) {
|
||||
request.requestBody = init.body.slice(0, this.options.maxBodySize);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingRequests.set(requestId, request);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.originalFetch(input, init);
|
||||
|
||||
// Update with response info if capturing
|
||||
if (shouldCapture) {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (request) {
|
||||
request.status = response.status;
|
||||
request.endTime = new Date().toISOString();
|
||||
request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime();
|
||||
|
||||
if (this.options.captureResponseHeaders) {
|
||||
request.responseHeaders = this.sanitizeHeaders(
|
||||
Object.fromEntries(response.headers.entries())
|
||||
);
|
||||
}
|
||||
|
||||
// Don't capture response body (too large/complex)
|
||||
// Just record that we received a response
|
||||
|
||||
this.pendingRequests.delete(requestId);
|
||||
this.onRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Record error if capturing
|
||||
if (shouldCapture) {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (request) {
|
||||
request.endTime = new Date().toISOString();
|
||||
request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime();
|
||||
request.error = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.pendingRequests.delete(requestId);
|
||||
this.onRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if interceptor is active
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
}
|
||||
233
packages/diagnostics-client/src/types.ts
Normal file
233
packages/diagnostics-client/src/types.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Core types for @bytelyst/diagnostics-client
|
||||
*
|
||||
* @module types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Log severity levels (matches syslog/OpenTelemetry)
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
|
||||
/**
|
||||
* Session status from the server
|
||||
*/
|
||||
export type SessionStatus = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Collection level determines verbosity of captured data
|
||||
*/
|
||||
export type CollectionLevel = 'standard' | 'debug' | 'trace';
|
||||
|
||||
/**
|
||||
* Diagnostic session configuration from server
|
||||
*/
|
||||
export interface DiagnosticsSession {
|
||||
/** Session ID */
|
||||
id: string;
|
||||
/** Product identifier */
|
||||
productId: string;
|
||||
/** Current session status */
|
||||
status: SessionStatus;
|
||||
/** Collection verbosity level */
|
||||
collectionLevel: CollectionLevel;
|
||||
/** Whether to capture logs */
|
||||
captureLogs: boolean;
|
||||
/** Whether to capture network traces */
|
||||
captureNetwork: boolean;
|
||||
/** Whether to capture screenshots */
|
||||
captureScreenshots: boolean;
|
||||
/** Auto-capture screenshot on error */
|
||||
screenshotOnError: boolean;
|
||||
/** Maximum session duration in minutes */
|
||||
maxDurationMinutes: number;
|
||||
/** Session creation time */
|
||||
createdAt: string;
|
||||
/** Session expiry time */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenTelemetry-compatible trace span
|
||||
*/
|
||||
export interface TraceSpan {
|
||||
/** Unique span ID */
|
||||
spanId: string;
|
||||
/** Parent span ID (null for root) */
|
||||
parentId?: string;
|
||||
/** Operation name */
|
||||
name: string;
|
||||
/** Span kind */
|
||||
kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer';
|
||||
/** Start time (ISO 8601) */
|
||||
startTime: string;
|
||||
/** End time (ISO 8601) */
|
||||
endTime?: string;
|
||||
/** Duration in milliseconds */
|
||||
durationMs?: number;
|
||||
/** Custom attributes */
|
||||
attributes: Record<string, unknown>;
|
||||
/** Status */
|
||||
status: 'ok' | 'error' | 'unset';
|
||||
/** Error message if status=error */
|
||||
statusMessage?: string;
|
||||
/** Nested events within this span */
|
||||
events?: Array<{
|
||||
name: string;
|
||||
timestamp: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured log entry
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/** Log level */
|
||||
level: LogLevel;
|
||||
/** Log message (PII redacted server-side) */
|
||||
message: string;
|
||||
/** Timestamp (ISO 8601) */
|
||||
timestamp: string;
|
||||
/** Module/component name */
|
||||
module: string;
|
||||
/** Source file path */
|
||||
file?: string;
|
||||
/** Line number */
|
||||
line?: number;
|
||||
/** Function name */
|
||||
function?: string;
|
||||
/** Additional context */
|
||||
context: Record<string, unknown>;
|
||||
/** Correlation ID for related operations */
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb for timeline navigation
|
||||
*/
|
||||
export interface Breadcrumb {
|
||||
/** Timestamp */
|
||||
timestamp: string;
|
||||
/** Category (e.g., 'navigation', 'user', 'error') */
|
||||
category: string;
|
||||
/** Message */
|
||||
message: string;
|
||||
/** Associated data */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network request/response capture
|
||||
*/
|
||||
export interface NetworkRequest {
|
||||
/** Unique request ID */
|
||||
id: string;
|
||||
/** URL */
|
||||
url: string;
|
||||
/** HTTP method */
|
||||
method: string;
|
||||
/** Request headers (sanitized) */
|
||||
requestHeaders: Record<string, string>;
|
||||
/** Request body (if captured) */
|
||||
requestBody?: string;
|
||||
/** Response status */
|
||||
status?: number;
|
||||
/** Response headers */
|
||||
responseHeaders?: Record<string, string>;
|
||||
/** Response body (if captured) */
|
||||
responseBody?: string;
|
||||
/** Start timestamp */
|
||||
startTime: string;
|
||||
/** End timestamp */
|
||||
endTime?: string;
|
||||
/** Duration in milliseconds */
|
||||
durationMs?: number;
|
||||
/** Error if request failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device state snapshot
|
||||
*/
|
||||
export interface DeviceState {
|
||||
/** Memory usage in MB */
|
||||
memoryMB?: number;
|
||||
/** Battery level (0-1) */
|
||||
batteryLevel?: number;
|
||||
/** Is battery charging */
|
||||
isCharging?: boolean;
|
||||
/** Available storage in MB */
|
||||
storageMB?: number;
|
||||
/** Network type (wifi, cellular, offline) */
|
||||
networkType?: string;
|
||||
/** Is device online */
|
||||
isOnline: boolean;
|
||||
/** Thermal state (nominal, fair, serious, critical) */
|
||||
thermalState?: 'nominal' | 'fair' | 'serious' | 'critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Client configuration options
|
||||
*/
|
||||
export interface DiagnosticsConfig {
|
||||
/** Product ID */
|
||||
productId: string;
|
||||
/** User ID (if authenticated) */
|
||||
userId?: string;
|
||||
/** Anonymous install ID */
|
||||
anonymousInstallId: string;
|
||||
/** Platform name */
|
||||
platform: string;
|
||||
/** Platform channel */
|
||||
channel: string;
|
||||
/** OS family */
|
||||
osFamily: string;
|
||||
/** App version */
|
||||
appVersion: string;
|
||||
/** Build number */
|
||||
buildNumber: string;
|
||||
/** Release channel */
|
||||
releaseChannel: string;
|
||||
/** Server base URL */
|
||||
serverUrl: string;
|
||||
/** Auth token provider */
|
||||
getAuthToken?: () => string | Promise<string>;
|
||||
/** Polling interval in ms (default: 5000) */
|
||||
pollIntervalMs?: number;
|
||||
/** Max breadcrumbs to keep (default: 100) */
|
||||
maxBreadcrumbs?: number;
|
||||
/** Auto-capture console logs */
|
||||
captureConsole?: boolean;
|
||||
/** Auto-capture uncaught errors */
|
||||
captureErrors?: boolean;
|
||||
/** Auto-capture network requests */
|
||||
captureNetwork?: boolean;
|
||||
/** URL patterns to exclude from network capture */
|
||||
networkExcludePatterns?: RegExp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client state
|
||||
*/
|
||||
export type ClientState =
|
||||
| { type: 'idle' }
|
||||
| { type: 'polling'; session: DiagnosticsSession | null }
|
||||
| { type: 'active'; session: DiagnosticsSession }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
/**
|
||||
* Ingest batch for sending to server
|
||||
*/
|
||||
export interface IngestBatch {
|
||||
/** Session ID */
|
||||
sessionId: string;
|
||||
/** Traces to ingest */
|
||||
traces?: TraceSpan[];
|
||||
/** Logs to ingest */
|
||||
logs?: LogEntry[];
|
||||
/** Breadcrumbs to ingest */
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
/** Network requests to ingest */
|
||||
network?: NetworkRequest[];
|
||||
}
|
||||
10
packages/diagnostics-client/tsconfig.json
Normal file
10
packages/diagnostics-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8564
pnpm-lock.yaml
generated
8564
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user