Phase 1 of the 4-package split called out in RoadMap #3. Moves the "infrastructure" leaves — files with zero business dependencies that @snowluma/bridge, @snowluma/core, and @snowluma/onebot all consume — into a dedicated workspace package. Mirrors what #4 (proto-defs) did for wire-protobuf definitions. Why --- - Cleaner layering: these were never bridge- or onebot-specific. Logger / hex codec / runtime config sit at the bottom of every module's import graph; giving them their own package makes that graph readable and prevents future leaks the other direction. - Sets up #3's bridge + onebot extractions: those need a clean leaf to depend on. Without this, every cross-package import would have to re-export through core, which defeats the split. - Mirrors @snowluma/proto-defs layout — per-file subpath exports (`@snowluma/common/logger`, `@snowluma/common/hex`, …) keep import sites unambiguous and let bundlers tree-shake aggressively. Layout ------ Moved (via `git mv` — history preserved): packages/core/src/utils/logger.ts → @snowluma/common/logger packages/core/src/utils/hex.ts → @snowluma/common/hex packages/core/src/utils/log-file-transport.ts → @snowluma/common/log-file-transport packages/core/src/utils/log-summary.ts → @snowluma/common/log-summary packages/core/src/protocol/packet-sender.ts → @snowluma/common/packet-sender packages/core/src/protocol/types.ts → @snowluma/common/protocol-types packages/core/src/common/runtime.ts → @snowluma/common/runtime Not moved (deferred to Phase 2): `packages/core/src/utils/event-format.ts` — it has reverse imports on `bridge/events`, `bridge/identity-service`, `onebot/message-store`, `onebot/types`. It moves into @snowluma/bridge in Phase 2, with the MessageStore parameter decoupled via a structural type so bridge doesn't end up depending on onebot. Changes ------- - New package `packages/common/` (package.json, tsconfig.json — no src/index.ts barrel, callers use the subpath exports directly to avoid pulling in `node:fs` when only the hex codec is needed). - 7 `.ts` files moved via `git mv`. - 50+ import sites rewritten via sed across `packages/core/src` and `packages/core/tests`. Old shapes (`../utils/logger`, `../../protocol/types`, `./common/runtime`, `../src/utils/hex`) all collapse to `@snowluma/common/<sub>`. - `@snowluma/core` depends on `@snowluma/common` (workspace:*). - `packages/core/src/{protocol,common}/` directories removed (empty). Verification ------------ - core typecheck: clean - common typecheck: clean - Full workspace `pnpm -r typecheck`: all 9 projects clean - core test suite: 552 passed / 552 total (pre-existing websocket-darwin-arm64.node addon failure unchanged — same baseline as the previous refactor commits) - Vite build with stubbed native binary: clean (143 modules, 2.84 MB bundle, 224 KB gzip — same size envelope as before) Note: `utils/event-format.ts` is the only file remaining under the old `utils/` namespace. It moves into @snowluma/bridge alongside the events / identity-service / packet-pipeline trio in Phase 2.
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
// Tests for the core logger API:
|
|
// - createLogger() output shape (scope rendering)
|
|
// - .child({ uin }) derivation and the [UIN] slot in the rendered line
|
|
// - LogEntry.uin propagation to subscribers
|
|
// - File transport per-UIN fan-out (and the SNOWLUMA_LOG_PER_UIN=0 gate)
|
|
//
|
|
// We exercise the file transport via its real fs paths against tmpdirs
|
|
// so the test catches integration regressions, not just unit shape.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { createLogger, getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
|
import { _resetFileTransportForTesting } from '@snowluma/common/log-file-transport';
|
|
|
|
let tmpDir: string;
|
|
const ENV_KEYS = [
|
|
'SNOWLUMA_LOG_FILE',
|
|
'SNOWLUMA_LOG_PER_UIN',
|
|
'SNOWLUMA_LOG_DIR',
|
|
'SNOWLUMA_LOG_MAX_MB',
|
|
'SNOWLUMA_LOG_RETAIN_DAYS',
|
|
'SNOWLUMA_LOG_LEVEL',
|
|
'NO_COLOR',
|
|
] as const;
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
function setEnv(env: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>): void {
|
|
for (const [k, v] of Object.entries(env)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-logger-'));
|
|
setEnv({
|
|
SNOWLUMA_LOG_DIR: tmpDir,
|
|
SNOWLUMA_LOG_FILE: '1',
|
|
SNOWLUMA_LOG_PER_UIN: '1',
|
|
SNOWLUMA_LOG_MAX_MB: '5',
|
|
SNOWLUMA_LOG_RETAIN_DAYS: '7',
|
|
NO_COLOR: '1',
|
|
});
|
|
await _resetFileTransportForTesting();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await _resetFileTransportForTesting();
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('createLogger', () => {
|
|
it('renders [Scope] with the empty UIN slot when no child binding', async () => {
|
|
const log = createLogger('Bridge');
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
log.info('hello');
|
|
unsub();
|
|
|
|
expect(captured).toHaveLength(1);
|
|
const entry = captured[0]!;
|
|
expect(entry.scope).toBe('Bridge');
|
|
expect(entry.uin).toBeUndefined();
|
|
// Plain-text line shape (NO_COLOR=1):
|
|
// HH:MM:SS · INFO_ · _<12 blank uin slot>_ · [Bridge] · msg
|
|
// where · = single separator space inside the template literal.
|
|
const tail = entry.line.slice(9); // drop "HH:MM:SS "
|
|
expect(tail).toBe(`INFO ${' '.repeat(12)} [Bridge] hello`);
|
|
});
|
|
|
|
it('child({ uin }) derives a logger that stamps the [UIN] slot', async () => {
|
|
const log = createLogger('Bridge').child({ uin: 12345 });
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
log.info('via child');
|
|
unsub();
|
|
|
|
expect(captured).toHaveLength(1);
|
|
const entry = captured[0]!;
|
|
expect(entry.uin).toBe(12345);
|
|
expect(entry.scope).toBe('Bridge');
|
|
// "[12345]" is 7 chars; padEnd to 12 leaves 5 trailing spaces inside
|
|
// the slot, then 1 separator space before "[Bridge]".
|
|
const tail = entry.line.slice(9);
|
|
expect(tail).toBe(`INFO [12345] [Bridge] via child`);
|
|
});
|
|
|
|
it('child() does not mutate the parent logger', async () => {
|
|
const parent = createLogger('Bridge');
|
|
const childA = parent.child({ uin: 100 });
|
|
const childB = parent.child({ uin: 200 });
|
|
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
parent.info('parent');
|
|
childA.info('a');
|
|
childB.info('b');
|
|
unsub();
|
|
|
|
expect(captured.map((e) => e.uin)).toEqual([undefined, 100, 200]);
|
|
});
|
|
|
|
it('child() can be chained; later uin wins, earlier meta is preserved', async () => {
|
|
const log = createLogger('Bridge').child({ uin: 100, foo: 'first' }).child({ bar: 'second' });
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
log.info('chained');
|
|
unsub();
|
|
|
|
// uin from first child survives through the second (which only sets bar)
|
|
expect(captured).toHaveLength(1);
|
|
expect(captured[0]!.uin).toBe(100);
|
|
});
|
|
|
|
it('writes the per-UIN sub-file in addition to the shared file', async () => {
|
|
const log = createLogger('Bridge').child({ uin: 67890 });
|
|
log.info('to file');
|
|
await _resetFileTransportForTesting();
|
|
|
|
const sharedFiles = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.log'));
|
|
expect(sharedFiles).toHaveLength(1);
|
|
const sharedPath = path.join(tmpDir, sharedFiles[0]!);
|
|
expect(fs.readFileSync(sharedPath, 'utf8')).toContain('[67890]');
|
|
|
|
const uinSubDir = path.join(tmpDir, '67890');
|
|
expect(fs.existsSync(uinSubDir)).toBe(true);
|
|
const uinFiles = fs.readdirSync(uinSubDir).filter((f) => f.endsWith('.log'));
|
|
expect(uinFiles).toHaveLength(1);
|
|
expect(fs.readFileSync(path.join(uinSubDir, uinFiles[0]!), 'utf8'))
|
|
.toContain('to file');
|
|
});
|
|
|
|
it('lines without uin go only to the shared file', async () => {
|
|
const log = createLogger('App');
|
|
log.info('no uin here');
|
|
await _resetFileTransportForTesting();
|
|
|
|
const shared = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.log'));
|
|
expect(shared).toHaveLength(1);
|
|
// No numeric sub-dirs should have been created.
|
|
const subDirs = fs.readdirSync(tmpDir, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name);
|
|
expect(subDirs).toHaveLength(0);
|
|
});
|
|
|
|
it('SNOWLUMA_LOG_PER_UIN=0 suppresses the per-UIN sub-file but keeps the shared one', async () => {
|
|
setEnv({ SNOWLUMA_LOG_PER_UIN: '0' });
|
|
await _resetFileTransportForTesting();
|
|
|
|
const log = createLogger('Bridge').child({ uin: 9999 });
|
|
log.info('one line');
|
|
await _resetFileTransportForTesting();
|
|
|
|
expect(fs.existsSync(path.join(tmpDir, '9999'))).toBe(false);
|
|
const shared = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.log'));
|
|
expect(shared).toHaveLength(1);
|
|
expect(fs.readFileSync(path.join(tmpDir, shared[0]!), 'utf8'))
|
|
.toContain('one line');
|
|
});
|
|
|
|
it('SNOWLUMA_LOG_FILE=0 also suppresses per-UIN files', async () => {
|
|
setEnv({ SNOWLUMA_LOG_FILE: '0' });
|
|
await _resetFileTransportForTesting();
|
|
|
|
const log = createLogger('Bridge').child({ uin: 1 });
|
|
log.info('hi');
|
|
await _resetFileTransportForTesting();
|
|
|
|
const entries = fs.readdirSync(tmpDir);
|
|
expect(entries).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('setLogLevel / getLogLevel — runtime level toggle', () => {
|
|
// Each test restores whatever level was active going in so the suite
|
|
// ordering can't bleed state between specs.
|
|
let saved: ReturnType<typeof getLogLevel>;
|
|
beforeEach(() => { saved = getLogLevel(); });
|
|
afterEach(() => { setLogLevel(saved); });
|
|
|
|
it('setLogLevel updates getLogLevel for valid input', () => {
|
|
expect(setLogLevel('warn')).toBe(true);
|
|
expect(getLogLevel()).toBe('warn');
|
|
expect(setLogLevel('debug')).toBe(true);
|
|
expect(getLogLevel()).toBe('debug');
|
|
});
|
|
|
|
it('setLogLevel rejects unknown values without mutating state', () => {
|
|
setLogLevel('info');
|
|
expect(setLogLevel('nonsense')).toBe(false);
|
|
expect(getLogLevel()).toBe('info');
|
|
expect(setLogLevel('')).toBe(false);
|
|
expect(setLogLevel('Verbose')).toBe(false);
|
|
});
|
|
|
|
it('setLogLevel is case-insensitive', () => {
|
|
expect(setLogLevel('WARN')).toBe(true);
|
|
expect(getLogLevel()).toBe('warn');
|
|
});
|
|
|
|
it('changes which entries reach the ring buffer / subscribers', () => {
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
const log = createLogger('LevelTest');
|
|
|
|
setLogLevel('warn');
|
|
log.debug('hidden-debug');
|
|
log.info('hidden-info');
|
|
log.warn('visible-warn');
|
|
log.error('visible-error');
|
|
|
|
unsub();
|
|
|
|
const messages = captured
|
|
.filter((e) => e.scope === 'LevelTest')
|
|
.map((e) => e.message);
|
|
expect(messages).toEqual(['visible-warn', 'visible-error']);
|
|
});
|
|
});
|