Files
SnowLuma/packages/onebot/tests/api-handler-logging.test.ts
DrSmoothl 9a30df9907 refactor(onebot): extract OneBot protocol translation into @snowluma/onebot
Phase 3 of the 4-package split (RoadMap #3). Moves the entire
`packages/core/src/onebot/` subtree (43 source files + 20 test files
spanning network adapters, action handlers, event converters, message
parser, message store, media store, instance + manager) into the new
`@snowluma/onebot` workspace package. The remaining `@snowluma/core`
now hosts only the api-layer (Bridge class + 13 Api classes), the
hook tooling, the WebUI server, and the runnable `index.ts`.

Why
---
- The OneBot adapter is the public-facing OneBot11 protocol surface;
  it has its own version cadence and its own consumers (third-party
  bots talking OneBot WS / HTTP). Splitting it lets that cadence move
  independently of the Api-on-ctx layer.
- The two were sharing the `packages/core/` build pipeline only
  because they happened to live in the same directory. Nothing in
  the api-layer reaches into OneBot internals.
- Mirrors NapCat / Lagrange.OneBot's repo split (their OneBot adapter
  is a separate assembly from the Core protocol library).

Layout
------
Moved via `git mv` (history preserved):
  packages/core/src/onebot/*  → @snowluma/onebot/src/<x>
  packages/core/tests/onebot/* (6 files)   → @snowluma/onebot/tests/
  packages/core/tests/{message-parser,message-store,event-pipeline,
    media-store,media-url-resolver,event-converter,
    http-server-hot-reload,http-server-content-type,
    api-handler-logging,message-id,group-msg-emoji-like,
    config,media-indexer,network-manager}.test.ts (14 files)
                                              → @snowluma/onebot/tests/

What stays in @snowluma/core
----------------------------
- `bridge/{bridge,bridge-interface,bridge-context,manager}.ts`
- `bridge/apis/*` (the 13 typed Api classes)
- `hook/*` (debugger/injector — tooling for the core protocol layer)
- `webui/*` (admin UI server — application wiring, not the OneBot
  adapter itself)
- `index.ts` (application entry that wires BridgeManager +
  OneBotManager + HookManager + WebUI together)

Changes
-------
- New package `packages/onebot/` (package.json with subpath exports
  mirroring legacy `core/src/onebot/<x>` 1:1, tsconfig.json,
  vitest.config.ts with `SNOWLUMA_LOG_LEVEL=debug` env so the action-
  dispatch logging tests see debug-level subscribers — copied verbatim
  from core's vitest.config.ts).
- @snowluma/core grows new subpath exports for the api-layer files
  OneBot's type-only imports need: `./bridge`, `./bridge-interface`,
  `./bridge-context`, `./manager`, `./apis`.
- `@snowluma/onebot` depends on `@snowluma/bridge`, `@snowluma/common`,
  `@snowluma/core`, `@snowluma/proto-defs`, `@snowluma/proton`,
  `@snowluma/websocket`.
- `@snowluma/core` depends on `@snowluma/onebot` (for index.ts +
  webui/server.ts wiring).
- 100+ import sites rewritten via sed:
  * OneBot → core: `'../bridge/bridge'` → `'@snowluma/core/bridge'`,
    `'../bridge/manager'` → `'@snowluma/core/manager'`,
    `'../bridge/bridge-interface'` → `'@snowluma/core/bridge-interface'`,
    `'@/bridge/bridge'` (vite path alias) → `'@snowluma/core/bridge'`.
  * Core → OneBot: `'./onebot/manager'` → `'@snowluma/onebot/manager'`,
    `'../onebot/config'` → `'@snowluma/onebot/config'`, etc.
  * Test relative paths: `'../../src/onebot/<x>'` → `'../src/<x>'`
    after the test file move (one level shallower).
- New `packages/onebot/src/env.d.ts` declaring `__APP_VERSION__` and
  `__BUILD_WEBUI__` (the vite-injected globals `info.ts` reads).

Workspace cycle
---------------
core ↔ onebot is allowed by pnpm workspaces. The runtime direction
is one-way: core → onebot (index.ts constructs OneBotManager,
webui/server reads OneBot config). OneBot only reaches into core for
TYPES (Bridge, BridgeInterface, BridgeManager, ClientKeyInfo,
DownloadRKeyInfo); those imports use `import type` so they compile
out at runtime and don't introduce a real cycle.

Verification
------------
- Full workspace `pnpm -r typecheck`: all 11 projects clean.
- `@snowluma/core` test suite: 302/302 pass (33 test files).
- `@snowluma/onebot` test suite: 250/250 pass (20 test files;
  pre-existing `network-manager.test.ts` native-addon failure
  unchanged — same baseline as before the split).
- Combined: 552/552 — identical pass count to the pre-Phase-3
  baseline.
- Vite build with stubbed native binary: clean, 2.84 MB bundle /
  224 KB gzip (identical envelope).
2026-05-21 08:52:00 +08:00

131 lines
4.8 KiB
TypeScript

// Tests for ApiHandler's central dispatch logging.
// Verifies that every action call emits a debug entry line, failures
// fold into a warn line with stack, and unknown actions are surfaced.
// Also covers the summarizeParams helper used to keep the entry log
// from blowing the line width.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ApiHandler } from '../src/api-handler';
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import { summarizeParams } from '@snowluma/common/log-summary';
// Minimal context — we never reach the real action handlers; we
// register our own via registerAction.
function emptyContext(): any {
return {
bridge: {},
getLoginInfo: () => ({ userId: 0, nickname: '' }),
isOnline: () => true,
getMessage: () => null,
getMessageMeta: () => null,
sendPrivateMessage: async () => ({ messageId: 0 }),
sendGroupMessage: async () => ({ messageId: 0 }),
deleteMessage: async () => {},
canSendImage: () => true,
canSendRecord: () => true,
getFriendList: async () => [],
getImageInfo: async () => null,
getRecordInfo: async () => null,
};
}
let captured: LogEntry[];
let unsub: () => void;
const savedLogLevel = process.env.SNOWLUMA_LOG_LEVEL;
beforeEach(() => {
process.env.SNOWLUMA_LOG_LEVEL = 'debug';
captured = [];
unsub = subscribeLogs((e) => captured.push(e));
});
afterEach(() => {
unsub();
if (savedLogLevel === undefined) delete process.env.SNOWLUMA_LOG_LEVEL;
else process.env.SNOWLUMA_LOG_LEVEL = savedLogLevel;
});
describe('summarizeParams', () => {
it('renders flat scalar fields as k=v pairs', () => {
const out = summarizeParams({ group_id: 12345, auto_escape: false, foo: 'bar' });
expect(out).toBe('group_id=12345 auto_escape=false foo="bar"');
});
it('truncates long string values with an ellipsis', () => {
const long = 'x'.repeat(80);
const out = summarizeParams({ s: long });
expect(out).toMatch(/^s="x{40}\.\.\."$/);
});
it('collapses arrays and nested objects rather than dumping them', () => {
const out = summarizeParams({ messages: [1, 2, 3, 4], meta: { a: 1, b: 2 } });
expect(out).toBe('messages=[len=4] meta={...}');
});
it('caps the total line and tags the cut with ellipsis', () => {
const params: Record<string, number> = {};
for (let i = 0; i < 30; i++) params[`field_with_some_padding_${i}`] = i;
const out = summarizeParams(params);
expect(out.length).toBeLessThan(220);
expect(out.endsWith('...')).toBe(true);
});
it('handles null / undefined inputs gracefully', () => {
expect(summarizeParams(null)).toBe('{}');
expect(summarizeParams(undefined)).toBe('{}');
});
});
describe('ApiHandler dispatch logging', () => {
it('emits a debug entry under [Bridge.Action] when an action is called', async () => {
const handler = new ApiHandler(emptyContext(), 12345);
handler.registerAction('echo', async () => ({ status: 'ok', retcode: 0, data: null }));
await handler.handle('echo', { group_id: 67890, message: [1, 2, 3] });
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry).toBeDefined();
expect(entry!.uin).toBe(12345);
expect(entry!.message).toContain('echo');
expect(entry!.message).toContain('group_id=67890');
expect(entry!.message).toContain('message=[len=3]');
});
it('emits a warn line with the error stack when the handler throws', async () => {
const handler = new ApiHandler(emptyContext(), 12345);
handler.registerAction('boom', async () => {
throw new Error('kapow');
});
const result = await handler.handle('boom', {});
expect(result.status).toBe('failed');
const warn = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'warn');
expect(warn).toBeDefined();
expect(warn!.message).toContain('boom');
expect(warn!.message).toContain('kapow');
// stack contains the test file path; just check that something stack-shaped is appended
expect(warn!.message).toMatch(/at\s+/);
});
it('logs unknown actions at debug level', async () => {
const handler = new ApiHandler(emptyContext(), 99);
await handler.handle('not_a_real_action', {});
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.message.includes('unknown action'));
expect(entry).toBeDefined();
expect(entry!.level).toBe('debug');
});
it('falls back to the module-level logger (no uin slot) when uin is omitted', async () => {
const handler = new ApiHandler(emptyContext());
handler.registerAction('ping', async () => ({ status: 'ok', retcode: 0, data: null }));
await handler.handle('ping', {});
const entry = captured.find((e) => e.scope === 'Bridge.Action' && e.level === 'debug');
expect(entry).toBeDefined();
expect(entry!.uin).toBeUndefined();
});
});