mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-16 13:05:53 +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');
|
||||
if (configResponse.ok) {
|
||||
const authConfig = await configResponse.json();
|
||||
console.log('🔧 Auth config:', authConfig);
|
||||
logger.log('🔧 Auth config:', authConfig);
|
||||
|
||||
if (authConfig.noAuth) {
|
||||
console.log('🔓 No auth required, bypassing authentication');
|
||||
logger.log('🔓 No auth required, bypassing authentication');
|
||||
this.isAuthenticated = true;
|
||||
this.currentView = 'list';
|
||||
await this.initializeServices(); // Initialize services after auth
|
||||
|
|
@ -180,11 +180,11 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not fetch auth config:', error);
|
||||
logger.warn('⚠️ Could not fetch auth config:', error);
|
||||
}
|
||||
|
||||
this.isAuthenticated = authClient.isAuthenticated();
|
||||
console.log('🔐 Authentication status:', this.isAuthenticated);
|
||||
logger.log('🔐 Authentication status:', this.isAuthenticated);
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
this.currentView = 'list';
|
||||
|
|
@ -197,7 +197,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
private async handleAuthSuccess() {
|
||||
console.log('✅ Authentication successful');
|
||||
logger.log('✅ Authentication successful');
|
||||
this.isAuthenticated = true;
|
||||
this.currentView = 'list';
|
||||
await this.initializeServices(); // Initialize services after auth
|
||||
|
|
@ -219,7 +219,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
private async initializeServices() {
|
||||
console.log('🚀 Initializing services...');
|
||||
logger.log('🚀 Initializing services...');
|
||||
try {
|
||||
// Initialize buffer subscription service for WebSocket connections
|
||||
await bufferSubscriptionService.initialize();
|
||||
|
|
@ -227,16 +227,16 @@ export class VibeTunnelApp extends LitElement {
|
|||
// Initialize push notification service
|
||||
await pushNotificationService.initialize();
|
||||
|
||||
console.log('✅ Services initialized successfully');
|
||||
logger.log('✅ Services initialized successfully');
|
||||
} 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
|
||||
// These are optional features
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLogout() {
|
||||
console.log('👋 Logging out');
|
||||
logger.log('👋 Logging out');
|
||||
await authClient.logout();
|
||||
this.isAuthenticated = false;
|
||||
this.currentView = 'auth';
|
||||
|
|
@ -316,7 +316,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId);
|
||||
if (!sessionExists) {
|
||||
// Session no longer exists, redirect to dashboard
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Selected session ${this.selectedSessionId} no longer exists, redirecting to dashboard`
|
||||
);
|
||||
this.selectedSessionId = null;
|
||||
|
|
@ -474,7 +474,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
private async handleHideExitedChange(e: CustomEvent) {
|
||||
console.log('handleHideExitedChange', {
|
||||
logger.log('handleHideExitedChange', {
|
||||
currentHideExited: this.hideExited,
|
||||
newHideExited: e.detail,
|
||||
});
|
||||
|
|
@ -491,7 +491,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
|
||||
// Add pre-animation class
|
||||
document.body.classList.add('sessions-animating');
|
||||
console.log('Added sessions-animating class');
|
||||
logger.log('Added sessions-animating class');
|
||||
|
||||
// Update state
|
||||
this.hideExited = e.detail;
|
||||
|
|
@ -499,17 +499,17 @@ export class VibeTunnelApp extends LitElement {
|
|||
|
||||
// Wait for render and trigger animations
|
||||
await this.updateComplete;
|
||||
console.log('Update complete, scheduling animation');
|
||||
logger.log('Update complete, scheduling animation');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Add specific animation direction
|
||||
const animationClass = wasHidingExited ? 'sessions-showing' : 'sessions-hiding';
|
||||
document.body.classList.add(animationClass);
|
||||
console.log('Added animation class:', animationClass);
|
||||
logger.log('Added animation class:', animationClass);
|
||||
|
||||
// Check what elements will be animated
|
||||
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 (isNearBottom) {
|
||||
|
|
@ -525,7 +525,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
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
|
||||
if (isNearBottom) {
|
||||
|
|
@ -890,12 +890,12 @@ export class VibeTunnelApp extends LitElement {
|
|||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
|
||||
// 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) => {
|
||||
// Handle browser back/forward navigation
|
||||
this.parseUrlAndSetState().catch(console.error);
|
||||
this.parseUrlAndSetState().catch((error) => logger.error('Error parsing URL:', error));
|
||||
};
|
||||
|
||||
private async parseUrlAndSetState() {
|
||||
|
|
@ -941,7 +941,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.currentView = 'session';
|
||||
} else {
|
||||
// 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.currentView = 'list';
|
||||
// Clear the session param from URL
|
||||
|
|
@ -1000,7 +1000,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.showLogLink = preferences.showLogLink || false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load app preferences', error);
|
||||
logger.error('Failed to load app preferences', error);
|
||||
}
|
||||
|
||||
// Listen for preference changes
|
||||
|
|
@ -1011,7 +1011,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
private handleOpenSettings = () => {
|
||||
console.log('🎯 handleOpenSettings called in app.ts');
|
||||
logger.log('🎯 handleOpenSettings called in app.ts');
|
||||
this.showSettings = true;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -311,7 +311,9 @@ describe('SessionCard', () => {
|
|||
|
||||
// Should use cleanup endpoint for exited sessions
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ export class SessionView extends LitElement {
|
|||
this.useDirectKeyboard = true; // Default to true when no settings exist
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { createLogger } from '../utils/logger.js';
|
||||
import { BrowserSSHAgent } from './ssh-agent.js';
|
||||
|
||||
const logger = createLogger('auth-client');
|
||||
|
||||
interface AuthResponse {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
|
|
@ -66,7 +69,7 @@ export class AuthClient {
|
|||
}
|
||||
throw new Error('Failed to get current user');
|
||||
} catch (error) {
|
||||
console.error('Failed to get current system user:', error);
|
||||
logger.error('Failed to get current system user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +93,7 @@ export class AuthClient {
|
|||
}
|
||||
}
|
||||
} 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
|
||||
|
|
@ -139,24 +142,24 @@ export class AuthClient {
|
|||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('🔐 SSH key auth server response:', result);
|
||||
logger.log('🔐 SSH key auth server response:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ SSH key auth successful, setting current user');
|
||||
logger.log('✅ SSH key auth successful, setting current user');
|
||||
this.setCurrentUser({
|
||||
userId: result.userId,
|
||||
token: result.token,
|
||||
authMethod: 'ssh-key',
|
||||
loginTime: Date.now(),
|
||||
});
|
||||
console.log('👤 Current user set:', this.getCurrentUser());
|
||||
logger.log('👤 Current user set:', this.getCurrentUser());
|
||||
} else {
|
||||
console.log('❌ SSH key auth failed:', result.error);
|
||||
logger.log('❌ SSH key auth failed:', result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('SSH key authentication failed:', error);
|
||||
logger.error('SSH key authentication failed:', error);
|
||||
return { success: false, error: 'SSH key authentication failed' };
|
||||
}
|
||||
}
|
||||
|
|
@ -185,7 +188,7 @@ export class AuthClient {
|
|||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Password authentication failed:', error);
|
||||
logger.error('Password authentication failed:', error);
|
||||
return { success: false, error: 'Password authentication failed' };
|
||||
}
|
||||
}
|
||||
|
|
@ -194,12 +197,12 @@ export class AuthClient {
|
|||
* Automated authentication - tries SSH keys first, then prompts for password
|
||||
*/
|
||||
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
|
||||
if (this.sshAgent.isUnlocked()) {
|
||||
const keys = this.sshAgent.listKeys();
|
||||
console.log(
|
||||
logger.log(
|
||||
'🗝️ Found SSH keys:',
|
||||
keys.length,
|
||||
keys.map((k) => ({ id: k.id, name: k.name }))
|
||||
|
|
@ -207,20 +210,20 @@ export class AuthClient {
|
|||
|
||||
for (const key of keys) {
|
||||
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);
|
||||
console.log(`🎯 SSH key ${key.name} result:`, result);
|
||||
logger.log(`🎯 SSH key ${key.name} result:`, result);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ Authenticated with SSH key: ${key.name}`);
|
||||
logger.log(`✅ Authenticated with SSH key: ${key.name}`);
|
||||
return result;
|
||||
}
|
||||
} 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 {
|
||||
console.log('🔒 SSH agent is locked');
|
||||
logger.log('🔒 SSH agent is locked');
|
||||
}
|
||||
|
||||
// SSH key auth failed or no keys available
|
||||
|
|
@ -246,7 +249,7 @@ export class AuthClient {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Server logout failed:', error);
|
||||
logger.warn('Server logout failed:', error);
|
||||
} finally {
|
||||
// Clear local state
|
||||
this.clearCurrentUser();
|
||||
|
|
@ -260,10 +263,7 @@ export class AuthClient {
|
|||
if (this.currentUser?.token) {
|
||||
return { Authorization: `Bearer ${this.currentUser.token}` };
|
||||
}
|
||||
// Suppress warning in test environment to reduce noise
|
||||
if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'test') {
|
||||
console.warn('⚠️ No token available for auth header');
|
||||
}
|
||||
// No warning needed when token is not available
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +281,7 @@ export class AuthClient {
|
|||
const result = await response.json();
|
||||
return result.valid;
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
logger.error('Token verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ export class AuthClient {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load current user:', error);
|
||||
logger.error('Failed to load current user:', error);
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
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
|
||||
*/
|
||||
|
|
@ -50,17 +93,27 @@ async function sendToServer(level: keyof LogLevel, module: string, args: unknown
|
|||
|
||||
// Check if we have authentication before sending logs
|
||||
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;
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authClient.getAuthHeader(),
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
module,
|
||||
|
|
@ -73,8 +126,9 @@ async function sendToServer(level: keyof LogLevel, module: string, args: unknown
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a logger for a specific module
|
||||
* This mirrors the server's createLogger interface
|
||||
* Creates a logger instance for a specific module
|
||||
* @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 {
|
||||
const createLogMethod = (level: keyof LogLevel): LogMethod => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue