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:
saravanakumardb1 2026-03-03 08:31:31 -08:00
parent 55a1256d8b
commit 6e0b6c33c9
11 changed files with 4065 additions and 6346 deletions

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

View 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"
}
}

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

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

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

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

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

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

View 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[];
}

View 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

File diff suppressed because it is too large Load Diff