mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix frontend log streaming in no-auth mode (#130)
This commit is contained in:
parent
099a5fb427
commit
39a5933f9f
6 changed files with 489 additions and 54 deletions
|
|
@ -167,10 +167,10 @@ export class VibeTunnelApp extends LitElement {
|
||||||
const configResponse = await fetch('/api/auth/config');
|
const configResponse = await fetch('/api/auth/config');
|
||||||
if (configResponse.ok) {
|
if (configResponse.ok) {
|
||||||
const authConfig = await configResponse.json();
|
const authConfig = await configResponse.json();
|
||||||
console.log('🔧 Auth config:', authConfig);
|
logger.log('🔧 Auth config:', authConfig);
|
||||||
|
|
||||||
if (authConfig.noAuth) {
|
if (authConfig.noAuth) {
|
||||||
console.log('🔓 No auth required, bypassing authentication');
|
logger.log('🔓 No auth required, bypassing authentication');
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
await this.initializeServices(); // Initialize services after auth
|
await this.initializeServices(); // Initialize services after auth
|
||||||
|
|
@ -180,11 +180,11 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('⚠️ Could not fetch auth config:', error);
|
logger.warn('⚠️ Could not fetch auth config:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isAuthenticated = authClient.isAuthenticated();
|
this.isAuthenticated = authClient.isAuthenticated();
|
||||||
console.log('🔐 Authentication status:', this.isAuthenticated);
|
logger.log('🔐 Authentication status:', this.isAuthenticated);
|
||||||
|
|
||||||
if (this.isAuthenticated) {
|
if (this.isAuthenticated) {
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
|
|
@ -197,7 +197,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAuthSuccess() {
|
private async handleAuthSuccess() {
|
||||||
console.log('✅ Authentication successful');
|
logger.log('✅ Authentication successful');
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
await this.initializeServices(); // Initialize services after auth
|
await this.initializeServices(); // Initialize services after auth
|
||||||
|
|
@ -219,7 +219,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeServices() {
|
private async initializeServices() {
|
||||||
console.log('🚀 Initializing services...');
|
logger.log('🚀 Initializing services...');
|
||||||
try {
|
try {
|
||||||
// Initialize buffer subscription service for WebSocket connections
|
// Initialize buffer subscription service for WebSocket connections
|
||||||
await bufferSubscriptionService.initialize();
|
await bufferSubscriptionService.initialize();
|
||||||
|
|
@ -227,16 +227,16 @@ export class VibeTunnelApp extends LitElement {
|
||||||
// Initialize push notification service
|
// Initialize push notification service
|
||||||
await pushNotificationService.initialize();
|
await pushNotificationService.initialize();
|
||||||
|
|
||||||
console.log('✅ Services initialized successfully');
|
logger.log('✅ Services initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to initialize services:', error);
|
logger.error('❌ Failed to initialize services:', error);
|
||||||
// Don't fail the whole app if services fail to initialize
|
// Don't fail the whole app if services fail to initialize
|
||||||
// These are optional features
|
// These are optional features
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleLogout() {
|
private async handleLogout() {
|
||||||
console.log('👋 Logging out');
|
logger.log('👋 Logging out');
|
||||||
await authClient.logout();
|
await authClient.logout();
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.currentView = 'auth';
|
this.currentView = 'auth';
|
||||||
|
|
@ -316,7 +316,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId);
|
const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId);
|
||||||
if (!sessionExists) {
|
if (!sessionExists) {
|
||||||
// Session no longer exists, redirect to dashboard
|
// Session no longer exists, redirect to dashboard
|
||||||
console.warn(
|
logger.warn(
|
||||||
`Selected session ${this.selectedSessionId} no longer exists, redirecting to dashboard`
|
`Selected session ${this.selectedSessionId} no longer exists, redirecting to dashboard`
|
||||||
);
|
);
|
||||||
this.selectedSessionId = null;
|
this.selectedSessionId = null;
|
||||||
|
|
@ -474,7 +474,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleHideExitedChange(e: CustomEvent) {
|
private async handleHideExitedChange(e: CustomEvent) {
|
||||||
console.log('handleHideExitedChange', {
|
logger.log('handleHideExitedChange', {
|
||||||
currentHideExited: this.hideExited,
|
currentHideExited: this.hideExited,
|
||||||
newHideExited: e.detail,
|
newHideExited: e.detail,
|
||||||
});
|
});
|
||||||
|
|
@ -491,7 +491,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
|
|
||||||
// Add pre-animation class
|
// Add pre-animation class
|
||||||
document.body.classList.add('sessions-animating');
|
document.body.classList.add('sessions-animating');
|
||||||
console.log('Added sessions-animating class');
|
logger.log('Added sessions-animating class');
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
this.hideExited = e.detail;
|
this.hideExited = e.detail;
|
||||||
|
|
@ -499,17 +499,17 @@ export class VibeTunnelApp extends LitElement {
|
||||||
|
|
||||||
// Wait for render and trigger animations
|
// Wait for render and trigger animations
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
console.log('Update complete, scheduling animation');
|
logger.log('Update complete, scheduling animation');
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// Add specific animation direction
|
// Add specific animation direction
|
||||||
const animationClass = wasHidingExited ? 'sessions-showing' : 'sessions-hiding';
|
const animationClass = wasHidingExited ? 'sessions-showing' : 'sessions-hiding';
|
||||||
document.body.classList.add(animationClass);
|
document.body.classList.add(animationClass);
|
||||||
console.log('Added animation class:', animationClass);
|
logger.log('Added animation class:', animationClass);
|
||||||
|
|
||||||
// Check what elements will be animated
|
// Check what elements will be animated
|
||||||
const cards = document.querySelectorAll('.session-flex-responsive > session-card');
|
const cards = document.querySelectorAll('.session-flex-responsive > session-card');
|
||||||
console.log('Found session cards to animate:', cards.length);
|
logger.log('Found session cards to animate:', cards.length);
|
||||||
|
|
||||||
// If we were near the bottom, maintain that position
|
// If we were near the bottom, maintain that position
|
||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
|
|
@ -525,7 +525,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
// Clean up after animation
|
// Clean up after animation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.body.classList.remove('sessions-animating', 'sessions-showing', 'sessions-hiding');
|
document.body.classList.remove('sessions-animating', 'sessions-showing', 'sessions-hiding');
|
||||||
console.log('Cleaned up animation classes');
|
logger.log('Cleaned up animation classes');
|
||||||
|
|
||||||
// Final scroll adjustment after animation completes
|
// Final scroll adjustment after animation completes
|
||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
|
|
@ -890,12 +890,12 @@ export class VibeTunnelApp extends LitElement {
|
||||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||||
|
|
||||||
// Parse initial URL and set state
|
// Parse initial URL and set state
|
||||||
this.parseUrlAndSetState().catch(console.error);
|
this.parseUrlAndSetState().catch((error) => logger.error('Error parsing URL:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePopState = (_event: PopStateEvent) => {
|
private handlePopState = (_event: PopStateEvent) => {
|
||||||
// Handle browser back/forward navigation
|
// Handle browser back/forward navigation
|
||||||
this.parseUrlAndSetState().catch(console.error);
|
this.parseUrlAndSetState().catch((error) => logger.error('Error parsing URL:', error));
|
||||||
};
|
};
|
||||||
|
|
||||||
private async parseUrlAndSetState() {
|
private async parseUrlAndSetState() {
|
||||||
|
|
@ -941,7 +941,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
this.currentView = 'session';
|
this.currentView = 'session';
|
||||||
} else {
|
} else {
|
||||||
// Session not found, go to list view
|
// Session not found, go to list view
|
||||||
console.warn(`Session ${sessionId} not found in sessions list`);
|
logger.warn(`Session ${sessionId} not found in sessions list`);
|
||||||
this.selectedSessionId = null;
|
this.selectedSessionId = null;
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
// Clear the session param from URL
|
// Clear the session param from URL
|
||||||
|
|
@ -1000,7 +1000,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
this.showLogLink = preferences.showLogLink || false;
|
this.showLogLink = preferences.showLogLink || false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load app preferences', error);
|
logger.error('Failed to load app preferences', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for preference changes
|
// Listen for preference changes
|
||||||
|
|
@ -1011,7 +1011,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOpenSettings = () => {
|
private handleOpenSettings = () => {
|
||||||
console.log('🎯 handleOpenSettings called in app.ts');
|
logger.log('🎯 handleOpenSettings called in app.ts');
|
||||||
this.showSettings = true;
|
this.showSettings = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,9 @@ describe('SessionCard', () => {
|
||||||
|
|
||||||
// Should use cleanup endpoint for exited sessions
|
// Should use cleanup endpoint for exited sessions
|
||||||
const calls = fetchMock.getCalls();
|
const calls = fetchMock.getCalls();
|
||||||
expect(calls[0][0]).toContain('/cleanup');
|
const cleanupCall = calls.find((call) => call[0].includes('/cleanup'));
|
||||||
|
expect(cleanupCall).toBeDefined();
|
||||||
|
expect(cleanupCall?.[0]).toContain('/cleanup');
|
||||||
expect(killedHandler).toHaveBeenCalled();
|
expect(killedHandler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ export class SessionView extends LitElement {
|
||||||
this.useDirectKeyboard = true; // Default to true when no settings exist
|
this.useDirectKeyboard = true; // Default to true when no settings exist
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load app preferences', error);
|
logger.error('Failed to load app preferences', error);
|
||||||
this.useDirectKeyboard = true; // Default to true on error
|
this.useDirectKeyboard = true; // Default to true on error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { createLogger } from '../utils/logger.js';
|
||||||
import { BrowserSSHAgent } from './ssh-agent.js';
|
import { BrowserSSHAgent } from './ssh-agent.js';
|
||||||
|
|
||||||
|
const logger = createLogger('auth-client');
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
|
@ -66,7 +69,7 @@ export class AuthClient {
|
||||||
}
|
}
|
||||||
throw new Error('Failed to get current user');
|
throw new Error('Failed to get current user');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get current system user:', error);
|
logger.error('Failed to get current system user:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +93,7 @@ export class AuthClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get user avatar:', error);
|
logger.error('Failed to get user avatar:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return generic avatar SVG for non-macOS or when no avatar found
|
// Return generic avatar SVG for non-macOS or when no avatar found
|
||||||
|
|
@ -139,24 +142,24 @@ export class AuthClient {
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('🔐 SSH key auth server response:', result);
|
logger.log('🔐 SSH key auth server response:', result);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('✅ SSH key auth successful, setting current user');
|
logger.log('✅ SSH key auth successful, setting current user');
|
||||||
this.setCurrentUser({
|
this.setCurrentUser({
|
||||||
userId: result.userId,
|
userId: result.userId,
|
||||||
token: result.token,
|
token: result.token,
|
||||||
authMethod: 'ssh-key',
|
authMethod: 'ssh-key',
|
||||||
loginTime: Date.now(),
|
loginTime: Date.now(),
|
||||||
});
|
});
|
||||||
console.log('👤 Current user set:', this.getCurrentUser());
|
logger.log('👤 Current user set:', this.getCurrentUser());
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ SSH key auth failed:', result.error);
|
logger.log('❌ SSH key auth failed:', result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('SSH key authentication failed:', error);
|
logger.error('SSH key authentication failed:', error);
|
||||||
return { success: false, error: 'SSH key authentication failed' };
|
return { success: false, error: 'SSH key authentication failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +188,7 @@ export class AuthClient {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Password authentication failed:', error);
|
logger.error('Password authentication failed:', error);
|
||||||
return { success: false, error: 'Password authentication failed' };
|
return { success: false, error: 'Password authentication failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,12 +197,12 @@ export class AuthClient {
|
||||||
* Automated authentication - tries SSH keys first, then prompts for password
|
* Automated authentication - tries SSH keys first, then prompts for password
|
||||||
*/
|
*/
|
||||||
async authenticate(userId: string): Promise<AuthResponse> {
|
async authenticate(userId: string): Promise<AuthResponse> {
|
||||||
console.log('🚀 Starting SSH authentication for user:', userId);
|
logger.log('🚀 Starting SSH authentication for user:', userId);
|
||||||
|
|
||||||
// Try SSH key authentication first if agent is unlocked
|
// Try SSH key authentication first if agent is unlocked
|
||||||
if (this.sshAgent.isUnlocked()) {
|
if (this.sshAgent.isUnlocked()) {
|
||||||
const keys = this.sshAgent.listKeys();
|
const keys = this.sshAgent.listKeys();
|
||||||
console.log(
|
logger.log(
|
||||||
'🗝️ Found SSH keys:',
|
'🗝️ Found SSH keys:',
|
||||||
keys.length,
|
keys.length,
|
||||||
keys.map((k) => ({ id: k.id, name: k.name }))
|
keys.map((k) => ({ id: k.id, name: k.name }))
|
||||||
|
|
@ -207,20 +210,20 @@ export class AuthClient {
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
try {
|
try {
|
||||||
console.log(`🔑 Trying SSH key: ${key.name} (${key.id})`);
|
logger.log(`🔑 Trying SSH key: ${key.name} (${key.id})`);
|
||||||
const result = await this.authenticateWithSSHKey(userId, key.id);
|
const result = await this.authenticateWithSSHKey(userId, key.id);
|
||||||
console.log(`🎯 SSH key ${key.name} result:`, result);
|
logger.log(`🎯 SSH key ${key.name} result:`, result);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(`✅ Authenticated with SSH key: ${key.name}`);
|
logger.log(`✅ Authenticated with SSH key: ${key.name}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`❌ SSH key authentication failed for key ${key.name}:`, error);
|
logger.warn(`❌ SSH key authentication failed for key ${key.name}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('🔒 SSH agent is locked');
|
logger.log('🔒 SSH agent is locked');
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH key auth failed or no keys available
|
// SSH key auth failed or no keys available
|
||||||
|
|
@ -246,7 +249,7 @@ export class AuthClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Server logout failed:', error);
|
logger.warn('Server logout failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
// Clear local state
|
// Clear local state
|
||||||
this.clearCurrentUser();
|
this.clearCurrentUser();
|
||||||
|
|
@ -260,10 +263,7 @@ export class AuthClient {
|
||||||
if (this.currentUser?.token) {
|
if (this.currentUser?.token) {
|
||||||
return { Authorization: `Bearer ${this.currentUser.token}` };
|
return { Authorization: `Bearer ${this.currentUser.token}` };
|
||||||
}
|
}
|
||||||
// Suppress warning in test environment to reduce noise
|
// No warning needed when token is not available
|
||||||
if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'test') {
|
|
||||||
console.warn('⚠️ No token available for auth header');
|
|
||||||
}
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +281,7 @@ export class AuthClient {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.valid;
|
return result.valid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
logger.error('Token verification failed:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +370,7 @@ export class AuthClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load current user:', error);
|
logger.error('Failed to load current user:', error);
|
||||||
this.clearCurrentUser();
|
this.clearCurrentUser();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
379
web/src/client/utils/logger.test.ts
Normal file
379
web/src/client/utils/logger.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { clearAuthConfigCache, createLogger, setDebugMode } from './logger.js';
|
||||||
|
|
||||||
|
// Mock the auth client module
|
||||||
|
vi.mock('../services/auth-client.js', () => ({
|
||||||
|
authClient: {
|
||||||
|
getAuthHeader: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
describe('Frontend Logger', () => {
|
||||||
|
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleDebugSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFetch.mockReset();
|
||||||
|
|
||||||
|
// Spy on console methods
|
||||||
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Reset debug mode
|
||||||
|
setDebugMode(false);
|
||||||
|
|
||||||
|
// Clear auth config cache
|
||||||
|
clearAuthConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Console Logging', () => {
|
||||||
|
it('should log to console with module prefix', () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.log('test message');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('[test-module]', 'test message');
|
||||||
|
|
||||||
|
logger.warn('warning message');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith('[test-module]', 'warning message');
|
||||||
|
|
||||||
|
logger.error('error message');
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('[test-module]', 'error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log debug messages when debug mode is disabled', () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.debug('debug message');
|
||||||
|
expect(consoleDebugSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log debug messages when debug mode is enabled', () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.debug('debug message');
|
||||||
|
expect(consoleDebugSpy).toHaveBeenCalledWith('[test-module]', 'debug message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple arguments', () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.log('message', { data: 'test' }, 123);
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('[test-module]', 'message', { data: 'test' }, 123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Server Logging - Authenticated Mode', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send logs to server when authenticated', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response());
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
logger.log('test message');
|
||||||
|
|
||||||
|
// Wait for async operations
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/logs/client', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: 'log',
|
||||||
|
module: 'test-module',
|
||||||
|
args: ['test message'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format objects as JSON strings', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response());
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
const testObj = { key: 'value', nested: { data: 123 } };
|
||||||
|
logger.log('message', testObj);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
|
||||||
|
const body = JSON.parse(lastCall[1].body);
|
||||||
|
expect(body.args).toEqual(['message', JSON.stringify(testObj, null, 2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all log levels', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response());
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.log('log message');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
logger.warn('warn message');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
logger.error('error message');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const calls = mockFetch.mock.calls.filter((call) => call[0] === '/api/logs/client');
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
expect(JSON.parse(calls[0][1].body).level).toBe('log');
|
||||||
|
expect(JSON.parse(calls[1][1].body).level).toBe('warn');
|
||||||
|
expect(JSON.parse(calls[2][1].body).level).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Server Logging - No-Auth Mode', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({});
|
||||||
|
|
||||||
|
// Mock auth config endpoint to return no-auth mode
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
if (url === '/api/auth/config') {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ noAuth: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send logs to server in no-auth mode without auth header', async () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
logger.log('test message in no-auth');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); // auth config + log
|
||||||
|
|
||||||
|
// Check auth config was fetched
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/auth/config');
|
||||||
|
|
||||||
|
// Check log was sent without auth header
|
||||||
|
const logCall = mockFetch.mock.calls.find((call) => call[0] === '/api/logs/client');
|
||||||
|
expect(logCall).toBeDefined();
|
||||||
|
expect(logCall?.[1].headers).toEqual({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
expect(JSON.parse(logCall?.[1].body)).toEqual({
|
||||||
|
level: 'log',
|
||||||
|
module: 'test-module',
|
||||||
|
args: ['test message in no-auth'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache auth config to reduce redundant requests', async () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.log('message 1');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
logger.log('message 2');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
logger.log('message 3');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// The logger should only check auth config once due to caching
|
||||||
|
const authConfigCalls = mockFetch.mock.calls.filter((call) => call[0] === '/api/auth/config');
|
||||||
|
expect(authConfigCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
// Should have sent all 3 log messages
|
||||||
|
const logCalls = mockFetch.mock.calls.filter((call) => call[0] === '/api/logs/client');
|
||||||
|
expect(logCalls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refetch auth config after cache expires', async () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
// First log - should fetch auth config
|
||||||
|
logger.log('message 1');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Clear cache to simulate expiration
|
||||||
|
clearAuthConfigCache();
|
||||||
|
|
||||||
|
// Second log - should fetch auth config again
|
||||||
|
logger.log('message 2');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Should have fetched auth config twice
|
||||||
|
const authConfigCalls = mockFetch.mock.calls.filter((call) => call[0] === '/api/auth/config');
|
||||||
|
expect(authConfigCalls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Server Logging - Not Authenticated', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({});
|
||||||
|
|
||||||
|
// Mock auth config endpoint to return auth required
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
if (url === '/api/auth/config') {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ noAuth: false }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not send logs when not authenticated and auth is required', async () => {
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
logger.log('test message');
|
||||||
|
|
||||||
|
// Wait a bit to ensure async operations complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Should only call auth config, not the log endpoint
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/auth/config');
|
||||||
|
|
||||||
|
const logCalls = mockFetch.mock.calls.filter((call) => call[0] === '/api/logs/client');
|
||||||
|
expect(logCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should silently ignore network errors', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => logger.log('test message')).not.toThrow();
|
||||||
|
|
||||||
|
// Console log should still work
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('[test-module]', 'test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle auth config fetch errors gracefully', async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({});
|
||||||
|
|
||||||
|
// Make auth config fetch fail
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Config fetch failed'));
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => logger.log('test message')).not.toThrow();
|
||||||
|
|
||||||
|
// Console log should still work
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('[test-module]', 'test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle circular objects gracefully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(new Response());
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
// Create circular reference
|
||||||
|
const circular: Record<string, unknown> = { a: 1 };
|
||||||
|
circular.self = circular;
|
||||||
|
|
||||||
|
logger.log('circular', circular);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
|
||||||
|
const body = JSON.parse(lastCall[1].body);
|
||||||
|
// Should convert to string when JSON.stringify fails
|
||||||
|
expect(body.args[1]).toBe('[object Object]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-200 responses from auth config', async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({});
|
||||||
|
|
||||||
|
// Mock auth config endpoint to return error
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
logger.log('test message');
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Should not send log when auth config fails
|
||||||
|
const logCalls = mockFetch.mock.calls.filter((call) => call[0] === '/api/logs/client');
|
||||||
|
expect(logCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Debug Mode', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { authClient } = await import('../services/auth-client.js');
|
||||||
|
vi.mocked(authClient.getAuthHeader).mockReturnValue({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
});
|
||||||
|
mockFetch.mockResolvedValue(new Response());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not send debug logs to server when debug mode is disabled', async () => {
|
||||||
|
setDebugMode(false);
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.debug('debug message');
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Should not call fetch
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send debug logs to server when debug mode is enabled', async () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
const logger = createLogger('test-module');
|
||||||
|
|
||||||
|
logger.debug('debug message');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
|
||||||
|
const body = JSON.parse(lastCall[1].body);
|
||||||
|
expect(body.level).toBe('debug');
|
||||||
|
expect(body.args).toEqual(['debug message']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,13 +16,56 @@ interface Logger {
|
||||||
|
|
||||||
let debugMode = false;
|
let debugMode = false;
|
||||||
|
|
||||||
|
// Auth config cache to reduce redundant requests
|
||||||
|
let authConfigCache: { noAuth: boolean; timestamp: number } | null = null;
|
||||||
|
const AUTH_CONFIG_TTL = 60000; // 1 minute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable debug mode
|
* Enable or disable debug mode for all loggers
|
||||||
|
* @param enabled - Whether to enable debug logging
|
||||||
*/
|
*/
|
||||||
export function setDebugMode(enabled: boolean): void {
|
export function setDebugMode(enabled: boolean): void {
|
||||||
debugMode = enabled;
|
debugMode = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the auth config cache (mainly for testing)
|
||||||
|
*/
|
||||||
|
export function clearAuthConfigCache(): void {
|
||||||
|
authConfigCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached auth configuration or fetch it
|
||||||
|
* @returns Whether no-auth mode is enabled
|
||||||
|
*/
|
||||||
|
async function getAuthConfig(): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached value if still valid
|
||||||
|
if (authConfigCache && now - authConfigCache.timestamp < AUTH_CONFIG_TTL) {
|
||||||
|
return authConfigCache.noAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and cache new value
|
||||||
|
try {
|
||||||
|
const configResponse = await fetch('/api/auth/config');
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const authConfig = await configResponse.json();
|
||||||
|
authConfigCache = {
|
||||||
|
noAuth: authConfig.noAuth === true,
|
||||||
|
timestamp: now,
|
||||||
|
};
|
||||||
|
return authConfigCache.noAuth;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore auth config fetch errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to false if fetch fails
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format arguments for consistent logging
|
* Format arguments for consistent logging
|
||||||
*/
|
*/
|
||||||
|
|
@ -50,17 +93,27 @@ async function sendToServer(level: keyof LogLevel, module: string, args: unknown
|
||||||
|
|
||||||
// Check if we have authentication before sending logs
|
// Check if we have authentication before sending logs
|
||||||
const authHeader = authClient.getAuthHeader();
|
const authHeader = authClient.getAuthHeader();
|
||||||
if (!authHeader.Authorization) {
|
|
||||||
// Skip sending logs if not authenticated
|
// Check if no-auth mode is enabled (cached)
|
||||||
|
const isNoAuthMode = await getAuthConfig();
|
||||||
|
|
||||||
|
// Skip sending logs if not authenticated AND not in no-auth mode
|
||||||
|
if (!authHeader.Authorization && !isNoAuthMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only add auth header if we have one
|
||||||
|
if (authHeader.Authorization) {
|
||||||
|
headers.Authorization = authHeader.Authorization;
|
||||||
|
}
|
||||||
|
|
||||||
await fetch('/api/logs/client', {
|
await fetch('/api/logs/client', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...authClient.getAuthHeader(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
level,
|
level,
|
||||||
module,
|
module,
|
||||||
|
|
@ -73,8 +126,9 @@ async function sendToServer(level: keyof LogLevel, module: string, args: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a logger for a specific module
|
* Creates a logger instance for a specific module
|
||||||
* This mirrors the server's createLogger interface
|
* @param moduleName - The name of the module for log context
|
||||||
|
* @returns Logger instance with log, warn, error, and debug methods
|
||||||
*/
|
*/
|
||||||
export function createLogger(moduleName: string): Logger {
|
export function createLogger(moduleName: string): Logger {
|
||||||
const createLogMethod = (level: keyof LogLevel): LogMethod => {
|
const createLogMethod = (level: keyof LogLevel): LogMethod => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue