feat(logger): daily rotation, 80 MB limit, 7-day retention, and log export

Overhaul the main-process logger and introduce a co-located unit test
  suite powered by Vitest.

  ### Logger changes (src/main/logger.ts)

  - Switch from a single main.log to daily files named main-YYYY-MM-DD.log
    via electron-log's resolvePathFn.
  - Raise per-file size limit from 10 MB to 80 MB before overflow rotation.
  - Add pruneOldLogs(): on every startup, delete main-*.log and
    main-*.old.log files whose mtime is older than 7 days.
  - Add getRecentMainLogEntries(): returns {archiveName, filePath}[] for
    all daily log files within the retention window, sorted alphabetically.
    Used by the log:exportZip IPC handler to bundle all recent logs.

  ### Log export (src/main/main.ts)

  - Import getRecentMainLogEntries alongside getLogFilePath.
  - log:exportZip now spreads getRecentMainLogEntries() instead of the
    single hardcoded main.log entry, so exported zips contain up to 7
    days of main-process logs.

  ### Testing infrastructure

  - Add Vitest (^4.1.0) as a dev dependency.
  - Add vitest.config.ts: includes src/**/*.test.ts, environment: node.
  - Replace the scattered test:memory / test:logger scripts with a single
    "test": "vitest run" command. Filter by module: npm test -- <name>.
  - Add electron-tsconfig.json exclude for src/**/*.test.* and
    src/**/*.spec.* so tsc never compiles test files.
  - Add src/main/logger.test.ts (co-located, 24 tests) covering:
    - filename pattern validation (daily + .old variants, rejection cases)
    - pruneOldLogs boundary behavior (retention cutoff, mixed files)
    - getRecentMainLogEntries filtering, ordering, and 7-day window

  ### Conventions and documentation

  - AGENTS.md / CLAUDE.md: rewrite Testing Guidelines — co-location
    required, .test.ts is the only accepted extension, Vitest imports only.
  - README.md / README_zh.md: add Testing section with run commands,
    directory layout, and a minimal example; both English and Chinese.

Signed-off-by: Dylan <[email protected]>
This commit is contained in:
Dylan
2026-03-20 15:22:06 +08:00
parent 36cab38796
commit c202b951dd
9 changed files with 382 additions and 12 deletions
+6 -3
View File
@@ -214,9 +214,12 @@ The Artifacts feature provides rich preview of code outputs similar to Claude's
## Testing Guidelines
- Tests use Node.js built-in `node:test` module (no Jest/Mocha/Vitest).
- Run tests: `npm run test:memory` (compiles Electron main process first, then runs `tests/coworkMemoryExtractor.test.mjs`).
- Test files live in `tests/` directory and import compiled output from `dist-electron/`.
- Unit tests use [Vitest](https://vitest.dev/) and are **co-located** with the source files they cover.
- Test files must use the `.test.ts` extension and be placed next to the source file (e.g. `src/main/foo.ts` → `src/main/foo.test.ts`).
- Import test utilities from `vitest`: `import { test, expect } from 'vitest';`
- **Never** use `.test.mjs` or any other extension — `.test.ts` is the only accepted format.
- Run all tests: `npm test`. Filter by module: `npm test -- <name>` (e.g. `npm test -- logger`).
- Avoid importing Electron-only APIs (e.g. `electron-log`) in tests — inline any logic that depends on them.
- Validate UI changes manually by running `npm run electron:dev` and exercising key flows:
- Cowork: start session, send prompts, approve/deny tool permissions, stop session
- Artifacts: preview HTML, SVG, Mermaid diagrams, React components
+35
View File
@@ -447,6 +447,41 @@ LobsterAI pins its OpenClaw dependency to a specific release version, declared i
- Tailwind CSS preferred; avoid custom CSS
- Commit messages follow `type: short imperative summary` (e.g., `feat: add artifact toolbar`)
## Testing
Unit tests use [Vitest](https://vitest.dev/) and are co-located with the source files they cover.
```bash
# run all tests
npm test
# run tests for a specific module (Vitest filename filter)
npm test -- logger
npm test -- cowork
```
New test files go next to the source file they test, using the `.test.mjs` extension:
```
src/main/
├── foo.ts
└── foo.test.ts
```
Example (`src/main/logger.test.ts`):
```ts
import { test, expect } from 'vitest';
test('log file pattern matches daily name', () => {
expect(/^main-\d{4}-\d{2}-\d{2}\.log$/.test('main-2026-03-20.log')).toBe(true);
});
```
Avoid importing Electron-only APIs (e.g. `electron-log`) in tests — inline any logic that depends on them instead.
## Contributing
1. Fork this repository
+33
View File
@@ -440,6 +440,39 @@ LobsterAI 将 OpenClaw 依赖锁定到指定的 release 版本,在 `package.js
| `OPENCLAW_FORCE_BUILD` | 设为 `1` 强制重新构建(即使版本匹配) | — |
| `OPENCLAW_SKIP_ENSURE` | 设为 `1` 跳过自动版本切换 | — |
## 测试
单元测试使用 [Vitest](https://vitest.dev/),测试文件与被测源文件**同目录存放**。
```bash
# 运行全部测试
npm test
# 只运行指定模块的测试(按文件名过滤)
npm test -- logger
npm test -- cowork
```
新增测试文件放在对应源文件旁边,使用 `.test.mjs` 扩展名:
```
src/main/
├── foo.ts
└── foo.test.ts
```
示例(`src/main/logger.test.ts`):
```ts
import { test, expect } from 'vitest';
test('log file pattern matches daily name', () => {
expect(/^main-\d{4}-\d{2}-\d{2}\.log$/.test('main-2026-03-20.log')).toBe(true);
});
```
避免在测试中引入 Electron 专属 API(如 `electron-log`),改为将相关逻辑内联到测试文件中。
## 开发规范
- TypeScript 严格模式,函数式组件 + Hooks
+2 -1
View File
@@ -15,5 +15,6 @@
"*": ["node_modules/*"]
}
},
"include": ["src/main", "src/common"]
"include": ["src/main", "src/common"],
"exclude": ["src/**/*.test.*", "src/**/*.spec.*"]
}
+2 -1
View File
@@ -53,7 +53,7 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test:memory": "npm run compile:electron && node --test tests/coworkMemoryExtractor.test.mjs",
"test": "vitest run",
"preview": "vite preview",
"compile:electron": "tsc --project electron-tsconfig.json",
"build:skill:web-search": "npm install --prefix SKILLs/web-search && npx tsc -p SKILLs/web-search/tsconfig.json",
@@ -170,6 +170,7 @@
"vite": "^5.1.4",
"vite-plugin-electron": "^0.28.0",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.0",
"wait-on": "^7.2.0"
}
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Unit tests for logger.ts logic:
* - Log filename pattern (daily rotation naming)
* - pruneOldLogs: which files get deleted
* - getRecentMainLogEntries: which files are included and ordering
*
* Logic is mirrored inline because electron-log cannot be loaded outside the
* Electron main process. Any change to logger.ts constants or regexes must be
* reflected here.
*/
import { test, expect } from 'vitest';
// ---------------------------------------------------------------------------
// Mirrors from logger.ts
// ---------------------------------------------------------------------------
const LOG_RETENTION_DAYS = 7;
const LOG_FILE_RE = /^main-\d{4}-\d{2}-\d{2}(\.old)?\.log$/;
type FileEntry = { name: string; mtimeMs: number };
/** Returns true when the file mtime is old enough to be pruned. */
function isPrunable(mtimeMs: number, nowMs: number): boolean {
return mtimeMs < nowMs - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
}
/** Returns true when the file mtime falls within the retention window. */
function isRecent(mtimeMs: number, nowMs: number): boolean {
return mtimeMs >= nowMs - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
}
/** Simulates getRecentMainLogEntries over a virtual directory. */
function recentEntries(files: FileEntry[], nowMs: number): Array<{ archiveName: string }> {
return files
.filter((f) => LOG_FILE_RE.test(f.name))
.filter((f) => isRecent(f.mtimeMs, nowMs))
.map((f) => ({ archiveName: f.name }))
.sort((a, b) => a.archiveName.localeCompare(b.archiveName));
}
/** Simulates pruneOldLogs: returns names of files that would be deleted. */
function filesToPrune(files: FileEntry[], nowMs: number): string[] {
return files
.filter((f) => LOG_FILE_RE.test(f.name))
.filter((f) => isPrunable(f.mtimeMs, nowMs))
.map((f) => f.name);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const NOW = new Date('2026-03-20T12:00:00Z').getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
function daysAgo(n: number): number {
return NOW - n * DAY_MS;
}
// ---------------------------------------------------------------------------
// Filename pattern
// ---------------------------------------------------------------------------
test('pattern: matches normal daily log', () => {
expect(LOG_FILE_RE.test('main-2026-03-20.log')).toBeTruthy();
});
test('pattern: matches .old variant', () => {
expect(LOG_FILE_RE.test('main-2026-03-19.old.log')).toBeTruthy();
});
test('pattern: matches oldest edge-case date', () => {
expect(LOG_FILE_RE.test('main-2026-03-13.log')).toBeTruthy();
});
test('pattern: rejects plain main.log', () => {
expect(LOG_FILE_RE.test('main.log')).toBeFalsy();
});
test('pattern: rejects cowork log', () => {
expect(LOG_FILE_RE.test('cowork.log')).toBeFalsy();
});
test('pattern: rejects partial date', () => {
expect(LOG_FILE_RE.test('main-2026-03.log')).toBeFalsy();
});
test('pattern: rejects non-log extension', () => {
expect(LOG_FILE_RE.test('main-2026-03-20.txt')).toBeFalsy();
});
test('pattern: rejects extra prefix', () => {
expect(LOG_FILE_RE.test('renderer-2026-03-20.log')).toBeFalsy();
});
// ---------------------------------------------------------------------------
// pruneOldLogs — boundary behavior
// ---------------------------------------------------------------------------
test('prune: file exactly at retention boundary is kept', () => {
const cutoffMs = NOW - LOG_RETENTION_DAYS * DAY_MS;
const files = [{ name: 'main-2026-03-13.log', mtimeMs: cutoffMs }];
expect(filesToPrune(files, NOW)).toEqual([]);
});
test('prune: file 1 ms before boundary is deleted', () => {
const cutoffMs = NOW - LOG_RETENTION_DAYS * DAY_MS;
const files = [{ name: 'main-2026-03-13.log', mtimeMs: cutoffMs - 1 }];
expect(filesToPrune(files, NOW)).toEqual(['main-2026-03-13.log']);
});
test("prune: today's file is not deleted", () => {
const files = [{ name: 'main-2026-03-20.log', mtimeMs: daysAgo(0) }];
expect(filesToPrune(files, NOW)).toEqual([]);
});
test('prune: file from 6 days ago is kept', () => {
const files = [{ name: 'main-2026-03-14.log', mtimeMs: daysAgo(6) }];
expect(filesToPrune(files, NOW)).toEqual([]);
});
test('prune: file from 8 days ago is deleted', () => {
const files = [{ name: 'main-2026-03-12.log', mtimeMs: daysAgo(8) }];
expect(filesToPrune(files, NOW)).toEqual(['main-2026-03-12.log']);
});
test('prune: .old variant from 8 days ago is deleted', () => {
const files = [{ name: 'main-2026-03-12.old.log', mtimeMs: daysAgo(8) }];
expect(filesToPrune(files, NOW)).toEqual(['main-2026-03-12.old.log']);
});
test('prune: non-matching files are never pruned', () => {
const files = [
{ name: 'cowork.log', mtimeMs: daysAgo(30) },
{ name: 'renderer.log', mtimeMs: daysAgo(30) },
{ name: 'main.log', mtimeMs: daysAgo(30) },
];
expect(filesToPrune(files, NOW)).toEqual([]);
});
test('prune: mixed — only old main-date files are deleted', () => {
const files = [
{ name: 'main-2026-03-20.log', mtimeMs: daysAgo(0) }, // keep
{ name: 'main-2026-03-15.log', mtimeMs: daysAgo(5) }, // keep
{ name: 'main-2026-03-12.log', mtimeMs: daysAgo(8) }, // delete
{ name: 'cowork.log', mtimeMs: daysAgo(30) }, // ignore
];
expect(filesToPrune(files, NOW)).toEqual(['main-2026-03-12.log']);
});
// ---------------------------------------------------------------------------
// getRecentMainLogEntries — filtering and ordering
// ---------------------------------------------------------------------------
test('entries: empty dir returns empty array', () => {
expect(recentEntries([], NOW)).toEqual([]);
});
test('entries: only non-matching files returns empty array', () => {
const files = [
{ name: 'cowork.log', mtimeMs: daysAgo(1) },
{ name: 'main.log', mtimeMs: daysAgo(1) },
];
expect(recentEntries(files, NOW)).toEqual([]);
});
test("entries: today's file is included", () => {
const files = [{ name: 'main-2026-03-20.log', mtimeMs: daysAgo(0) }];
const result = recentEntries(files, NOW);
expect(result.length).toBe(1);
expect(result[0].archiveName).toBe('main-2026-03-20.log');
});
test('entries: file from exactly 7 days ago (at cutoff) is included', () => {
const cutoffMs = NOW - LOG_RETENTION_DAYS * DAY_MS;
const files = [{ name: 'main-2026-03-13.log', mtimeMs: cutoffMs }];
expect(recentEntries(files, NOW).length).toBe(1);
});
test('entries: file older than 7 days is excluded', () => {
const files = [{ name: 'main-2026-03-12.log', mtimeMs: daysAgo(8) }];
expect(recentEntries(files, NOW).length).toBe(0);
});
test('entries: .old variant within retention is included', () => {
const files = [{ name: 'main-2026-03-19.old.log', mtimeMs: daysAgo(1) }];
const result = recentEntries(files, NOW);
expect(result.length).toBe(1);
expect(result[0].archiveName).toBe('main-2026-03-19.old.log');
});
test('entries: results are sorted alphabetically', () => {
const files = [
{ name: 'main-2026-03-20.log', mtimeMs: daysAgo(0) },
{ name: 'main-2026-03-17.log', mtimeMs: daysAgo(3) },
{ name: 'main-2026-03-19.log', mtimeMs: daysAgo(1) },
{ name: 'main-2026-03-18.log', mtimeMs: daysAgo(2) },
];
const names = recentEntries(files, NOW).map((e) => e.archiveName);
expect(names).toEqual([
'main-2026-03-17.log',
'main-2026-03-18.log',
'main-2026-03-19.log',
'main-2026-03-20.log',
]);
});
test('entries: full 7-day window all included, day 8 excluded', () => {
const files = Array.from({ length: 9 }, (_, i) => ({
name: `main-2026-03-${String(20 - i).padStart(2, '0')}.log`,
mtimeMs: daysAgo(i),
}));
const result = recentEntries(files, NOW);
expect(result.length >= 7).toBeTruthy();
expect(result.some((e) => e.archiveName === 'main-2026-03-12.log')).toBeFalsy();
});
+78 -5
View File
@@ -3,21 +3,48 @@
* Intercepts console.* methods and writes to file + console simultaneously.
*
* Log file locations:
* macOS: ~/Library/Logs/LobsterAI/main.log
* Windows: %USERPROFILE%\AppData\Roaming\LobsterAI\logs\main.log
* Linux: ~/.config/LobsterAI/logs/main.log
* macOS: ~/Library/Logs/LobsterAI/main-YYYY-MM-DD.log
* Windows: %USERPROFILE%\AppData\Roaming\LobsterAI\logs\main-YYYY-MM-DD.log
* Linux: ~/.config/LobsterAI/logs/main-YYYY-MM-DD.log
*
* Rotation policy:
* - Daily log files (one file per calendar day)
* - Max 80 MB per file; on overflow electron-log rotates to .old.log
* - Files older than 7 days are pruned on startup
*/
import path from 'path';
import fs from 'fs';
import log from 'electron-log/main';
const LOG_RETENTION_DAYS = 7;
const LOG_MAX_SIZE = 80 * 1024 * 1024; // 80 MB
/** Captured on first resolvePathFn call; used for pruning and export. */
let _logDir: string | undefined;
function todayStr(): string {
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
}
function logDir(): string {
return _logDir ?? path.dirname(log.transports.file.getFile().path);
}
/**
* Initialize logging system.
* Must be called early in main process, before any console output.
*/
export function initLogger(): void {
// Daily rotation: one file per calendar day
log.transports.file.resolvePathFn = (vars) => {
_logDir = vars.libraryDefaultDir;
return path.join(vars.libraryDefaultDir, `main-${todayStr()}.log`);
};
// File transport config
log.transports.file.level = 'debug';
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB, then rotate to main.old.log
log.transports.file.maxSize = LOG_MAX_SIZE;
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
// Console transport config
@@ -59,19 +86,65 @@ export function initLogger(): void {
// (we already call originalLog above, so electron-log only needs to write to file)
log.transports.console.level = false;
// Remove log files older than retention window
pruneOldLogs();
// Log startup marker
log.info('='.repeat(60));
log.info(`LobsterAI started (${process.platform} ${process.arch})`);
log.info('='.repeat(60));
}
/** Delete daily main-*.log files whose mtime exceeds the retention window. */
function pruneOldLogs(): void {
const dir = logDir();
if (!fs.existsSync(dir)) return;
const cutoffMs = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of fs.readdirSync(dir)) {
if (!/^main-\d{4}-\d{2}-\d{2}(\.old)?\.log$/.test(file)) continue;
const filePath = path.join(dir, file);
try {
if (fs.statSync(filePath).mtimeMs < cutoffMs) {
fs.unlinkSync(filePath);
}
} catch {
// ignore individual failures
}
}
}
/**
* Get the current log file path
* Get today's log file path (for display / open-in-folder).
*/
export function getLogFilePath(): string {
return log.transports.file.getFile().path;
}
/**
* Return archive entries for all daily main log files within the last 7 days.
* Suitable for passing directly to exportLogsZip.
*/
export function getRecentMainLogEntries(): Array<{ archiveName: string; filePath: string }> {
const dir = logDir();
if (!fs.existsSync(dir)) return [];
const cutoffMs = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
return fs.readdirSync(dir)
.filter((f) => /^main-\d{4}-\d{2}-\d{2}(\.old)?\.log$/.test(f))
.map((f) => ({ archiveName: f, filePath: path.join(dir, f) }))
.filter(({ filePath }) => {
try {
return fs.statSync(filePath).mtimeMs >= cutoffMs;
} catch {
return false;
}
})
.sort((a, b) => a.archiveName.localeCompare(b.archiveName));
}
/**
* Log instance for direct usage if needed
*/
+2 -2
View File
@@ -58,7 +58,7 @@ import { McpServerManager } from './libs/mcpServerManager';
import { McpBridgeServer } from './libs/mcpBridgeServer';
import type { McpBridgeConfig } from './libs/openclawConfigSync';
import { downloadUpdate, installUpdate, cancelActiveDownload } from './libs/appUpdateInstaller';
import { initLogger, getLogFilePath } from './logger';
import { initLogger, getLogFilePath, getRecentMainLogEntries } from './logger';
import { getCoworkLogPath } from './libs/coworkLogger';
import { exportLogsZip } from './libs/logExport';
import { ensurePythonRuntimeReady } from './libs/pythonRuntime';
@@ -1616,7 +1616,7 @@ if (!gotTheLock) {
const archiveResult = await exportLogsZip({
outputPath,
entries: [
{ archiveName: 'main.log', filePath: getLogFilePath() },
...getRecentMainLogEntries(),
{ archiveName: 'cowork.log', filePath: getCoworkLogPath() },
],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});