Files
DrSmoothl 46b302f738 refactor(common): extract leaf utilities into @snowluma/common package
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.
2026-05-21 08:27:05 +08:00

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']);
});
});