Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7fc5abf
chore(desktop-main): T1 - add winston-daily-rotate-file ^5.0.0 depend…
CoderCoco May 23, 2026
a1090c3
feat(desktop-main): T2 - rewrite logger.ts to createLogger factory wi…
CoderCoco May 23, 2026
cdccf99
feat(desktop-main): T3 - call createLogger with userData/logs path be…
CoderCoco May 23, 2026
7180700
feat(desktop-main): T4 - add DiagnosticsService with log tail and pat…
CoderCoco May 23, 2026
e35baa5
feat(desktop-main): T5 - add DiagnosticsController with GET tail and …
CoderCoco May 23, 2026
f2a6856
feat(desktop-main): T6 - register DiagnosticsService and controller i…
CoderCoco May 23, 2026
dec71d5
feat(web): T7 - add diagnosticsTail and diagnosticsLogPath to api ser…
CoderCoco May 23, 2026
8538c99
feat(web): T8 - add DiagnosticsPanel component with 5s polling and au…
CoderCoco May 23, 2026
a52f23e
feat(web): T9 - add Diagnostics section to SettingsPage with Diagnost…
CoderCoco May 23, 2026
51c117f
perf(desktop-main): tail log from end-of-file to avoid full read on e…
CoderCoco May 23, 2026
3f635f2
fix(desktop-main): guard Electron API usage, fix bytesRead, lazy elec…
CoderCoco May 23, 2026
f8d64a0
fix(desktop-main): per-transport formats and cancel-safe fetchData
CoderCoco May 23, 2026
f36452b
fix(desktop-main): lazy-load electron via dynamic import after guard
CoderCoco May 23, 2026
b6a23b4
test(web): remove clearAllTimers from global afterEach in Diagnostics…
CoderCoco May 23, 2026
3e82e4e
fix(web): retry logPath fetch until successful in DiagnosticsPanel
CoderCoco May 23, 2026
8c6dd60
fix(desktop-main): default DIAGNOSTICS_LOG_DIR to os.tmpdir() in non-…
CoderCoco May 23, 2026
f4de79c
fix(desktop-main): peek one byte before window to avoid dropping comp…
CoderCoco May 23, 2026
a1133ec
test(desktop-main): use path.join/dirname in getTodayLogPath assertions
CoderCoco May 23, 2026
a5c8211
test(desktop-main): remove empty beforeEach from logger singleton suite
CoderCoco May 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/packages/desktop-main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"nestjs-electron-ipc-transport": "^1.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"winston": "^3.13.0"
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
Comment thread
CoderCoco marked this conversation as resolved.
},
"devDependencies": {
"@types/express": "^4.17.21",
Expand Down
21 changes: 20 additions & 1 deletion app/packages/desktop-main/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as path from 'node:path';
import { createRequire } from 'module';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AwsModule } from './modules/aws.module.js';
Expand All @@ -9,7 +11,9 @@ import { LogsController } from './controllers/logs.controller.js';
import { FilesController } from './controllers/files.controller.js';
import { DiscordController } from './controllers/discord.controller.js';
import { EnvController } from './controllers/env.controller.js';
import { DiagnosticsController } from './controllers/diagnostics.controller.js';
import { ApiTokenGuard } from './guards/api-token.guard.js';
import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsService.js';

/**
* Root Nest module. Wires the feature modules (`AwsModule`, `DiscordModule`) to
Expand All @@ -29,7 +33,22 @@ import { ApiTokenGuard } from './guards/api-token.guard.js';
FilesController,
DiscordController,
EnvController,
DiagnosticsController,
],
providers: [
{ provide: APP_GUARD, useClass: ApiTokenGuard },
{
provide: DIAGNOSTICS_LOG_DIR,
useFactory: () => {
if (!process.versions['electron']) {
return process.env['DIAGNOSTICS_LOG_DIR'] ?? '';
}
Comment thread
CoderCoco marked this conversation as resolved.
const _require = createRequire(import.meta.url);
const { app } = _require('electron') as { app: { getPath(name: string): string } };
return path.join(app.getPath('userData'), 'logs');
},
},
DiagnosticsService,
],
providers: [{ provide: APP_GUARD, useClass: ApiTokenGuard }],
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'reflect-metadata';
import { describe, it, expect, vi } from 'vitest';
import { DiagnosticsController } from './diagnostics.controller.js';
import type { DiagnosticsService } from '../services/DiagnosticsService.js';

vi.mock('../logger.js', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));

/** Build a DiagnosticsService stub. */
function makeDiagnostics(): DiagnosticsService {
return {
readTail: vi.fn().mockResolvedValue(['line1', 'line2', 'line3']),
getTodayLogPath: vi.fn().mockReturnValue('/var/log/app/main-2026-05-23.log'),
} as unknown as DiagnosticsService;
}

describe('DiagnosticsController', () => {
describe('getTail', () => {
it('should return lines from DiagnosticsService', async () => {
const svc = makeDiagnostics();
const result = await new DiagnosticsController(svc).getTail();
expect(result).toEqual({ lines: ['line1', 'line2', 'line3'] });
});

it('should call DiagnosticsService.readTail with 500 lines', async () => {
const svc = makeDiagnostics();
await new DiagnosticsController(svc).getTail();
expect(svc.readTail).toHaveBeenCalledWith(500);
});
});

describe('getPath', () => {
it('should return the current log path from DiagnosticsService', () => {
const svc = makeDiagnostics();
const result = new DiagnosticsController(svc).getPath();
expect(result).toEqual({ path: '/var/log/app/main-2026-05-23.log' });
});

it('should delegate to DiagnosticsService.getTodayLogPath', () => {
const svc = makeDiagnostics();
new DiagnosticsController(svc).getPath();
expect(svc.getTodayLogPath).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { DiagnosticsService } from '../services/DiagnosticsService.js';

/** Exposes local application log data for operator diagnostics. */
@Controller('diagnostics')
export class DiagnosticsController {
constructor(private readonly diagnostics: DiagnosticsService) {}

/** Returns the last 500 lines from today's local log file. */
@Get('tail')
async getTail(): Promise<{ lines: string[] }> {
const lines = await this.diagnostics.readTail(500);
return { lines };
}

/** Returns the absolute path of today's local log file. */
@Get('path')
getPath(): { path: string } {
return { path: this.diagnostics.getTodayLogPath() };
}
}
72 changes: 72 additions & 0 deletions app/packages/desktop-main/src/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from 'vitest';
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';

/**
* Re-import logger module fresh for each test that cares about the singleton
* state. We use a plain import at the top for the factory tests; the
* singleton re-assignment tests use the same module reference.
*/
import { createLogger, logger as initialLogger } from './logger.js';

describe('createLogger', () => {
it('should return a winston.Logger instance', () => {
const result = createLogger('/tmp/test-logs');
// Winston's createLogger returns a DerivedLogger that extends EventEmitter.
// Check the duck-typed API surface rather than constructor identity, which
// can differ when the same package is loaded from two module cache entries.
expect(typeof result.info).toBe('function');
expect(typeof result.error).toBe('function');
expect(typeof result.debug).toBe('function');
expect(Array.isArray(result.transports)).toBe(true);
});

it('should include a DailyRotateFile transport in the transports array', () => {
const result = createLogger('/tmp/test-logs');
const hasRotate = result.transports.some((t) => t instanceof DailyRotateFile);
expect(hasRotate).toBe(true);
});

it('should include a Console transport in the transports array', () => {
const result = createLogger('/tmp/test-logs');
const hasConsole = result.transports.some(
(t) => t instanceof winston.transports.Console,
);
expect(hasConsole).toBe(true);
});

it('should configure the DailyRotateFile transport with the provided logDir', () => {
const logDir = '/tmp/my-custom-log-dir';
const result = createLogger(logDir);
const rotateTransport = result.transports.find(
(t) => t instanceof DailyRotateFile,
) as InstanceType<typeof DailyRotateFile> | undefined;

expect(rotateTransport).toBeDefined();
// The `dirname` option is stored as `options.dirname` on the transport.
const opts = (rotateTransport as unknown as { options: { dirname: string } })
.options;
expect(opts.dirname).toBe(logDir);
});
});

describe('logger singleton', () => {
beforeEach(() => {
// Reset to console-only by re-creating; each test starts fresh.
});

it('should reassign the exported logger binding when createLogger is called', async () => {
// Capture the initial (console-only) reference.
const beforeCall = initialLogger;
Comment thread
CoderCoco marked this conversation as resolved.

// createLogger mutates the exported binding.
const returned = createLogger('/tmp/test-logs-singleton');

// The returned value and the freshly-imported binding must be the same object.
// We re-import via the same module to read the live binding.
const { logger: afterCall } = await import('./logger.js');
expect(afterCall).toBe(returned);
// And it must differ from the pre-call fallback logger.
expect(afterCall).not.toBe(beforeCall);
});
});
86 changes: 67 additions & 19 deletions app/packages/desktop-main/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,72 @@
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';

const isDev = process.env['NODE_ENV'] !== 'production';

export const logger = winston.createLogger({
level: isDev ? 'debug' : 'info',
format: isDev
? winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf((info) => {
const { timestamp, level, message, ...meta } = info as Record<string, unknown>;
const metaStr = Object.keys(meta).length
? '\n' + JSON.stringify(meta, null, 2)
: '';
return `${timestamp} [${level}] ${message}${metaStr}`;
}),
)
: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [new winston.transports.Console()],
const devPrintf = winston.format.printf((info) => {
const { timestamp, level, message, ...meta } = info as Record<string, unknown>;
const metaStr = Object.keys(meta).length ? '\n' + JSON.stringify(meta, null, 2) : '';
return `${timestamp} [${level}] ${message}${metaStr}`;
});

/** Format for the Console transport — colorized in dev for readability. */
const consoleFormat = isDev
? winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
devPrintf,
)
: winston.format.combine(winston.format.timestamp(), winston.format.json());

/** Format for file transports — no ANSI escape codes, safe for log parsers. */
const fileFormat = isDev
? winston.format.combine(winston.format.timestamp({ format: 'HH:mm:ss' }), devPrintf)
: winston.format.combine(winston.format.timestamp(), winston.format.json());

/**
* Creates a console-only fallback logger used as the initial singleton value
* before {@link createLogger} is called by main.ts.
*/
function createConsoleOnlyLogger(): winston.Logger {
return winston.createLogger({
level: isDev ? 'debug' : 'info',
format: consoleFormat,
transports: [new winston.transports.Console()],
});
}

/**
* Module-level logger singleton. Initially console-only; reassigned (as a
* live ESM binding) when {@link createLogger} is called from main.ts so that
* all existing importers automatically see the upgraded instance.
*/
export let logger: winston.Logger = createConsoleOnlyLogger();

/**
* Creates a full winston logger with both a Console transport and a
* DailyRotateFile transport that writes to `logDir`. Also reassigns the
* exported {@link logger} singleton so that modules that imported it before
* `main.ts` started still get the upgraded instance.
*
* @param logDir - Directory in which daily log files will be written.
* @returns The newly created {@link winston.Logger} instance.
*/
export function createLogger(logDir: string): winston.Logger {
const newLogger = winston.createLogger({
level: isDev ? 'debug' : 'info',
transports: [
new winston.transports.Console({ format: consoleFormat }),
new DailyRotateFile({
format: fileFormat,
dirname: logDir,
filename: 'main-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: '14d',
}),
],
});

// Reassign the live ESM binding so importers see the upgraded instance.
logger = newLogger;
return newLogger;
}
32 changes: 30 additions & 2 deletions app/packages/desktop-main/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
* vi.mock() factory functions run (vi.mock calls are hoisted to the top of the
* compiled output, above regular const/let declarations).
*/
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock } = vi.hoisted(() => {
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock } = vi.hoisted(() => {
/** Fake NestJS microservice app returned by `NestFactory.createMicroservice`. */
const fakeApp = { listen: vi.fn().mockResolvedValue(undefined) };
/** Spy constructor for ElectronIPCTransport — tracks `new` invocations. */
Expand All @@ -15,9 +15,21 @@ const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathM
const createMicroserviceMock = vi.fn().mockResolvedValue(fakeApp);
/** Spy for `applyFixPath` — verifies the fix-path bootstrap is called during startup. */
const applyFixPathMock = vi.fn();
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock };
/** Fake Electron `app` object with a `getPath` spy. */
const mockApp = { getPath: vi.fn().mockReturnValue('/fake/userData') };
/** Spy for `createLogger` — verifies the file logger is initialised before bootstrap. */
const createLoggerMock = vi.fn();
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock, mockApp, createLoggerMock };
});

vi.mock('electron', () => ({
app: mockApp,
}));

vi.mock('./logger.js', () => ({
createLogger: createLoggerMock,
}));

vi.mock('nestjs-electron-ipc-transport', () => ({
ElectronIPCTransport: ElectronIPCTransportMock,
}));
Expand Down Expand Up @@ -52,6 +64,8 @@ describe('main bootstrap', () => {
createMicroserviceMock.mockResolvedValue(fakeApp);
fakeApp.listen.mockResolvedValue(undefined);
applyFixPathMock.mockImplementation(() => undefined);
mockApp.getPath.mockReturnValue('/fake/userData');
createLoggerMock.mockImplementation(() => undefined);
// Simulate an Electron main-process environment so the module-level guard passes.
vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '36.0.0' } });
});
Expand Down Expand Up @@ -110,4 +124,18 @@ describe('main bootstrap', () => {
// applyFixPath must be invoked exactly once before NestFactory.createMicroservice.
expect(applyFixPathMock).toHaveBeenCalledTimes(1);
});

it('should resolve userData path and initialise the file logger before bootstrap', async () => {
vi.resetModules();
await import('./main.js');

// Flush the event loop so the async bootstrap chain fully resolves.
await new Promise<void>((resolve) => setTimeout(resolve, 0));

// Electron app.getPath should have been called with 'userData' to derive the log directory.
expect(mockApp.getPath).toHaveBeenCalledWith('userData');

// createLogger must have been called with the userData/logs path before NestFactory boots.
expect(createLoggerMock).toHaveBeenCalledWith('/fake/userData/logs');
});
});
6 changes: 6 additions & 0 deletions app/packages/desktop-main/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import 'reflect-metadata';
import path from 'node:path';
import { app } from 'electron';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions } from '@nestjs/microservices';
Comment thread
CoderCoco marked this conversation as resolved.
import { ElectronIPCTransport } from 'nestjs-electron-ipc-transport';
import { AppModule } from './app.module.js';
import { applyFixPath } from './fix-path-bootstrap.js';
import { createLogger } from './logger.js';

applyFixPath();

// ElectronIPCTransport requires ipcMain, which is only available inside an
// Electron main process. Fail fast with a readable message rather than a
// cryptic module-resolution error when someone runs `node dist/main.js`.
// This guard must run before any Electron API calls (e.g. app.getPath).
if (!process.versions['electron']) {
throw new Error(
'desktop-main must run inside an Electron main process. ' +
'Launch via Electron — running with plain Node is not supported.',
);
}

createLogger(path.join(app.getPath('userData'), 'logs'));

async function bootstrap(): Promise<void> {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
strategy: new ElectronIPCTransport(),
Expand Down
Loading
Loading