Files
SnowLuma/packages/onebot/tests/network-manager.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

181 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { IOneBotNetworkAdapter, NetworkReloadType, OneBotNetworkManager } from '../src/network';
import type { NetworkAdapterContext } from '../src/network';
import type { JsonObject, NetworkBase } from '../src/types';
import type { DispatchPayload } from '../src/event-filter';
interface FakeNetworkConfig extends NetworkBase {
// Allows tests to influence behavior via config:
failOnEvent?: boolean;
}
class FakeAdapter extends IOneBotNetworkAdapter<FakeNetworkConfig> {
events: JsonObject[] = [];
opens = 0;
closes = 0;
reloads = 0;
// Last dispatch payload object identity, to assert it was reused per emit.
lastPayloads: DispatchPayload[] = [];
open(): void {
this.opens++;
if (this.config.enabled !== false) this.isEnabled = true;
}
close(): void {
this.closes++;
this.isEnabled = false;
}
reload(next: FakeNetworkConfig): NetworkReloadType {
this.reloads++;
this.config = structuredClone(next);
if (next.enabled === false && this.isEnabled) {
this.close();
return NetworkReloadType.Closed;
}
if (next.enabled !== false && !this.isEnabled) {
this.open();
return NetworkReloadType.Opened;
}
return NetworkReloadType.Normal;
}
onEvent(event: JsonObject, payload: DispatchPayload): void {
this.events.push(event);
this.lastPayloads.push(payload);
if (this.config.failOnEvent) throw new Error('boom');
}
}
const NULL_CTX: NetworkAdapterContext = {
uin: '10001',
api: { handle: async () => ({ status: 'failed', retcode: 0, data: null }), processRequest: async () => '' } as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};
function makeAdapter(name: string, partial: Partial<FakeNetworkConfig> = {}): FakeAdapter {
const config: FakeNetworkConfig = {
name,
enabled: true,
messageFormat: 'array',
reportSelfMessage: false,
...partial,
};
return new FakeAdapter(name, config, NULL_CTX);
}
const SAMPLE_EVENT: JsonObject = {
time: 1,
self_id: 10001,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 1,
user_id: 22222,
message: [{ type: 'text', data: { text: 'hi' } }],
raw_message: 'hi',
font: 0,
sender: { user_id: 22222, nickname: 'peer', sex: 'unknown', age: 0 },
};
describe('OneBotNetworkManager', () => {
it('opens and emits to every active adapter in parallel', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
expect(a.opens).toBe(1);
expect(b.opens).toBe(1);
await mgr.emitEvent(SAMPLE_EVENT);
expect(a.events).toEqual([SAMPLE_EVENT]);
expect(b.events).toEqual([SAMPLE_EVENT]);
// Both adapters should receive the same payload reference (built once).
expect(a.lastPayloads[0]).toBe(b.lastPayloads[0]);
});
it('skips inactive adapters during emit', async () => {
const mgr = new OneBotNetworkManager();
const active = makeAdapter('active');
const idle = makeAdapter('idle', { enabled: false });
mgr.register(active);
mgr.register(idle);
await mgr.openAll();
await mgr.emitEvent(SAMPLE_EVENT);
expect(active.events).toHaveLength(1);
expect(idle.events).toHaveLength(0);
});
it('isolates adapter errors so one bad adapter does not block others', async () => {
const mgr = new OneBotNetworkManager();
const ok = makeAdapter('ok');
const bad = makeAdapter('bad', { failOnEvent: true });
mgr.register(ok);
mgr.register(bad);
await mgr.openAll();
await mgr.emitEvent(SAMPLE_EVENT);
// The good adapter still saw the event.
expect(ok.events).toHaveLength(1);
// The bad adapter logged the throw but didn't crash the manager.
expect(bad.events).toHaveLength(1);
});
it('replaces an existing adapter under the same name and closes the old one', async () => {
const mgr = new OneBotNetworkManager();
const first = makeAdapter('dup');
mgr.register(first);
await mgr.openAll();
const second = makeAdapter('dup');
mgr.register(second);
expect(mgr.get('dup')).toBe(second);
expect(first.closes).toBe(1);
});
it('closeOne shuts down a single adapter and removes it', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
await mgr.closeOne('a');
expect(mgr.has('a')).toBe(false);
expect(a.closes).toBe(1);
expect(b.closes).toBe(0);
expect(mgr.hasActiveAdapters()).toBe(true);
});
it('closeAll clears every adapter', async () => {
const mgr = new OneBotNetworkManager();
const a = makeAdapter('a');
const b = makeAdapter('b');
mgr.register(a);
mgr.register(b);
await mgr.openAll();
await mgr.closeAll();
expect(mgr.list()).toHaveLength(0);
expect(a.closes).toBe(1);
expect(b.closes).toBe(1);
});
it('hasActiveAdapters reflects current state', async () => {
const mgr = new OneBotNetworkManager();
expect(mgr.hasActiveAdapters()).toBe(false);
const a = makeAdapter('a');
mgr.register(a);
expect(mgr.hasActiveAdapters()).toBe(false);
await mgr.openAll();
expect(mgr.hasActiveAdapters()).toBe(true);
await mgr.closeAll();
expect(mgr.hasActiveAdapters()).toBe(false);
});
});