Files
SnowLuma/packages/bridge/tests/hook-session.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

2240 lines
82 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookSession } from '../src/hook-session';
import type { ManualMapHandle } from '../src/injector';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
type QqHookClient,
type QqHookPacket,
} from '../src/qq-hook-client';
import type { PacketSender } from '@snowluma/common/packet-sender';
import type { PacketSink } from '@snowluma/common/protocol-types';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
const previousLogLevel = getLogLevel();
function sessionTrace(entries: LogEntry[]): LogEntry[] {
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'HookSession');
}
afterEach(() => {
setLogLevel(previousLogLevel);
});
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
/** Minimal stand-in for QqHookClient. Tests drive login/packet/error/close
* events directly via fire* helpers. connectAll succeeds unless told otherwise. */
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
shouldFailConnect = false;
runtimeErrorOnConnect = false;
nativeReadyOnIdentityHint = false;
identityHintFailures = 0;
sendError: Error | null = null;
readonly sentPackets: Array<{ serviceCmd: string; body: Buffer }> = [];
readonly loginIdentityHints: string[] = [];
private loginState = { loggedIn: false, uin: '0', uinNumber: 0n };
async connectAll(_opts: { recv: boolean }): Promise<void> {
if (this.runtimeErrorOnConnect) {
this.emit('runtimeError', new Error('QQ service unavailable'));
}
if (this.shouldFailConnect) {
throw new Error('connect failed');
}
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { ...this.loginState };
}
reconcileLoginState(state: { loggedIn: boolean; uin: string; uinNumber: bigint }): void {
this.isLoggedIn = state.loggedIn;
this.loginState = { ...state };
this.emit('loginState', { ...this.loginState });
}
async reconcileLoginIdentity(uin: string): Promise<void> {
this.loginIdentityHints.push(uin);
if (this.identityHintFailures > 0) {
this.identityHintFailures--;
throw new Error('identity hint unavailable');
}
if (this.nativeReadyOnIdentityHint) {
this.fireLogin(uin);
}
}
async send(serviceCmd: string, body: Buffer) {
if (this.sendError) {
throw this.sendError;
}
this.sentPackets.push({ serviceCmd, body: Buffer.from(body) });
return {
requestId: this.sentPackets.length,
error: 0,
message: '',
body: Buffer.from('reply'),
};
}
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
fireLogin(uin: string): void {
this.reconcileLoginState({ loggedIn: true, uin, uinNumber: BigInt(uin) });
}
firePacket(packet: QqHookPacket): void {
this.emit('packet', packet);
}
}
function makeSession(
opts: {
pid?: number;
pipeLive?: boolean;
clientFailsConnect?: boolean;
runtimeErrorOnConnect?: boolean;
onPacket?: PacketSink;
detectLoginUin?: (pid: number) => Promise<string | null>;
nativeReadyOnIdentityHint?: boolean;
identityHintFailures?: number;
connectClient?: (client: FakeClient) => Promise<void>;
} = {},
) {
const pid = opts.pid ?? 1234;
let pipeLive = opts.pipeLive ?? false;
const clients: FakeClient[] = [];
const injector = {
inject: vi.fn(async () => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })),
unload: vi.fn(async () => {}),
};
// Default detection exposes no account so the reconcile safety net is inert
// unless a test opts into a known one.
const detectLoginUin = opts.detectLoginUin ?? (async () => null);
const session = new HookSession(pid, {
injector,
makeClient: () => {
const c = new FakeClient();
c.runtimeErrorOnConnect = opts.runtimeErrorOnConnect ?? false;
if (opts.clientFailsConnect) {
c.shouldFailConnect = true;
}
c.nativeReadyOnIdentityHint = opts.nativeReadyOnIdentityHint ?? false;
c.identityHintFailures = opts.identityHintFailures ?? 0;
if (opts.connectClient) {
c.connectAll = () => opts.connectClient!(c);
}
clients.push(c);
// Cast: FakeClient mirrors only the surface HookSession touches.
return c as unknown as QqHookClient;
},
detectLoginUin,
pipeWatcher: { isPipeLive: () => pipeLive },
onPacket: opts.onPacket,
});
return {
session,
injector,
clients,
currentClient: () => clients[clients.length - 1]!,
setPipeLive: (v: boolean) => {
pipeLive = v;
},
};
}
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('HookSession runtime failure', () => {
it('retains an initialization error instead of waiting for login', async () => {
const { session } = makeSession({ pipeLive: true, runtimeErrorOnConnect: true });
await session.load();
session.onPipeUp();
await flush();
expect(session.toInfo()).toMatchObject({
status: 'error',
error: 'QQ service unavailable',
connected: false,
});
session.dispose();
});
it('keeps a late runtime error visible across refresh and login snapshots', async () => {
const { session, currentClient, setPipeLive } = makeSession({ pipeLive: true });
const disconnect = vi.fn();
session.on('disconnected', disconnect);
await session.load();
session.onPipeUp();
await flush();
currentClient().fireLogin('10001');
currentClient().emit('runtimeError', new Error('QQ service unavailable'));
currentClient().emit('error', new Error('connection closed'));
expect(session.error).toBe('QQ service unavailable');
currentClient().fireLogin('10001');
await session.refresh();
expect(session.toInfo()).toMatchObject({
status: 'error',
error: 'QQ service unavailable',
loggedIn: false,
});
setPipeLive(false);
await session.refresh();
expect(disconnect).toHaveBeenCalledExactlyOnceWith(true);
setPipeLive(true);
await session.refresh();
currentClient().fireLogin('10001');
expect(session.toInfo()).toMatchObject({ status: 'online', loggedIn: true, error: '' });
session.dispose();
});
it('retains the failure when the pipe closes and discards an in-flight login lookup', async () => {
let resolveLogin!: (uin: string) => void;
const { session, currentClient, setPipeLive } = makeSession({
pipeLive: true,
detectLoginUin: () =>
new Promise((resolve) => {
resolveLogin = resolve;
}),
});
await session.load();
session.onPipeUp();
await flush();
const client = currentClient();
client.emit('runtimeError', new Error('QQ service unavailable'));
resolveLogin('10001');
await flush();
expect(client.loginIdentityHints).toEqual([]);
setPipeLive(false);
await session.refresh();
expect(session.toInfo()).toMatchObject({
status: 'error',
error: 'QQ service unavailable',
loggedIn: false,
connected: false,
});
session.dispose();
});
});
describe('HookSession — runtime TRACE', () => {
it('records injection and existing-pipe load branches with unique terminals', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const injected = makeSession({ pid: 7101, pipeLive: false });
const reconnect = makeSession({ pid: 7102, pipeLive: true });
await injected.session.load();
await reconnect.session.load();
const messages = sessionTrace(entries).map((entry) => entry.message);
expect(messages).toEqual(
expect.arrayContaining([
expect.stringContaining('hook_session_start pid=7101 operation=load'),
'hook_session_branch pid=7101 operation=load branch=inject_begin',
'hook_session_branch pid=7101 operation=load branch=inject_completed method="loadModuleManual"',
expect.stringMatching(
/^hook_session_terminal pid=7101 operation=load outcome=completed reason=load_settled state=.*elapsedMs=\d+$/,
),
expect.stringContaining('hook_session_start pid=7102 operation=load'),
'hook_session_branch pid=7102 operation=load branch=existing_pipe method="reconnect"',
expect.stringMatching(
/^hook_session_terminal pid=7102 operation=load outcome=completed reason=load_settled state=.*elapsedMs=\d+$/,
),
]),
);
for (const pid of [7101, 7102]) {
expect(
messages.filter((message) =>
message.startsWith(`hook_session_terminal pid=${pid} operation=load `),
),
).toHaveLength(1);
}
} finally {
unsubscribe();
}
});
it('records load and connection failures with truthful settled state', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const injector = {
inject: vi.fn(async () => {
throw new Error('fixture inject failure');
}),
unload: vi.fn(async () => {}),
};
const failedLoad = new HookSession(7103, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
await failedLoad.load();
const failedConnect = makeSession({
pid: 7104,
pipeLive: true,
clientFailsConnect: true,
});
await failedConnect.session.load();
failedConnect.session.onPipeUp();
await flush();
const messages = sessionTrace(entries).map((entry) => entry.message);
expect(messages).toEqual(
expect.arrayContaining([
expect.stringMatching(
/^hook_session_terminal pid=7103 operation=load outcome=failed reason=load_failed error="fixture inject failure" state=.*elapsedMs=\d+$/,
),
expect.stringContaining('hook_session_connect_start pid=7104'),
expect.stringMatching(
/^hook_session_connect_terminal pid=7104 outcome=failed reason=connect_failed error="connect failed" state=.*elapsedMs=\d+$/,
),
]),
);
expect(
messages.filter((message) =>
message.startsWith('hook_session_connect_terminal pid=7104 '),
),
).toHaveLength(1);
} finally {
unsubscribe();
}
});
it('connects when load fails but the pipe is already up', async () => {
let pipeLive = false;
const clients: FakeClient[] = [];
const session = new HookSession(7108, {
injector: {
inject: async () => {
pipeLive = true;
throw new Error('component loading failed [COMPONENT_LOAD_FAILED]');
},
unload: async () => undefined,
},
makeClient: () => {
const client = new FakeClient();
client.fireLogin('10001');
clients.push(client);
return client as unknown as QqHookClient;
},
pipeWatcher: {
isPipeLive: () => pipeLive,
tickNow: async () => undefined,
},
});
const info = await session.load();
expect(info.status).toBe('online');
expect(info.uin).toBe('10001');
expect(info.connected).toBe(true);
expect(clients).toHaveLength(1);
});
it('records login, disconnect, and process-gone only when state changes', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeSession({ pid: 7105, pipeLive: true });
try {
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.currentClient().fireLogin('10001');
ctx.session.onPipeDown();
await flush();
ctx.session.onPipeDown();
await flush();
ctx.session.notifyProcessGone();
await flush();
const messages = sessionTrace(entries).map((entry) => entry.message);
expect(
messages.filter((message) =>
message.startsWith('hook_session_login_terminal pid=7105 '),
),
).toHaveLength(1);
expect(
messages.filter((message) =>
message.startsWith('hook_session_disconnect_terminal pid=7105 '),
),
).toHaveLength(1);
expect(
messages.filter((message) =>
message.startsWith('hook_session_process_terminal pid=7105 '),
),
).toHaveLength(1);
expect(messages).toEqual(
expect.arrayContaining([
expect.stringMatching(
/^hook_session_login_terminal pid=7105 outcome=completed reason=login_detected previousUin="0" uin="10001" state=.*elapsedMs=\d+$/,
),
expect.stringMatching(
/^hook_session_disconnect_terminal pid=7105 outcome=completed reason=pipe_down wasLoggedIn=true state=.*elapsedMs=\d+$/,
),
expect.stringMatching(
/^hook_session_process_terminal pid=7105 outcome=completed reason=process_gone wasLoggedIn=false state=.*elapsedMs=\d+$/,
),
]),
);
} finally {
unsubscribe();
ctx.session.dispose();
}
});
it('records failed terminals when lifecycle listeners throw', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const login = makeSession({ pid: 7107, pipeLive: true });
const disconnect = makeSession({ pid: 7108, pipeLive: true });
const processGone = makeSession({ pid: 7109, pipeLive: true });
try {
await login.session.load();
login.session.onPipeUp();
await flush();
login.session.on('login', () => {
throw new Error('login listener boom');
});
expect(() => login.currentClient().fireLogin('10001')).toThrow('login listener boom');
await disconnect.session.load();
disconnect.session.onPipeUp();
await flush();
disconnect.currentClient().fireLogin('10002');
disconnect.session.on('disconnected', () => {
throw new Error('disconnect listener boom');
});
disconnect.session.onPipeDown();
await flush();
processGone.session.on('disposed', () => {
throw new Error('dispose listener boom');
});
processGone.session.notifyProcessGone();
await flush();
const messages = sessionTrace(entries).map((entry) => entry.message);
expect(
messages.filter((message) =>
message.startsWith('hook_session_login_terminal pid=7107 '),
),
).toEqual([
expect.stringMatching(
/^hook_session_login_terminal pid=7107 outcome=failed reason=listener_failed error="login listener boom" state=.*elapsedMs=\d+$/,
),
]);
expect(
messages.filter((message) =>
message.startsWith('hook_session_disconnect_terminal pid=7108 '),
),
).toEqual([
expect.stringMatching(
/^hook_session_disconnect_terminal pid=7108 outcome=failed reason=listener_failed error="disconnect listener boom" state=.*elapsedMs=\d+$/,
),
]);
expect(
messages.filter((message) =>
message.startsWith('hook_session_process_terminal pid=7109 '),
),
).toEqual([
expect.stringMatching(
/^hook_session_process_terminal pid=7109 outcome=failed reason=listener_failed error="dispose listener boom" state=.*elapsedMs=\d+$/,
),
]);
} finally {
unsubscribe();
login.session.dispose();
disconnect.session.dispose();
processGone.session.dispose();
}
});
it('records unload verification failure as a failed terminal', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeSession({ pid: 7106, pipeLive: false });
try {
await ctx.session.load();
ctx.setPipeLive(true);
await ctx.session.unload();
const terminals = sessionTrace(entries).filter((entry) =>
entry.message.startsWith('hook_session_terminal pid=7106 operation=unload '),
);
expect(terminals).toHaveLength(1);
expect(terminals[0]!.message).toMatch(
/^hook_session_terminal pid=7106 operation=unload outcome=failed reason=unload_verification_failed error=.*state=.*elapsedMs=\d+$/,
);
} finally {
unsubscribe();
}
});
});
describe('HookSession — load', () => {
it('with no live pipe: injects, status → connecting, method from inject result', async () => {
const { session, injector } = makeSession({ pipeLive: false });
const info = await session.load();
expect(injector.inject).toHaveBeenCalledOnce();
expect(info.status).toBe('connecting');
expect(info.method).toBe('loadModuleManual');
expect(info.injected).toBe(true);
});
it('fast-path: pipe already live → skip inject, method → "reconnect"', async () => {
const { session, injector } = makeSession({ pipeLive: true });
const info = await session.load();
expect(injector.inject).not.toHaveBeenCalled();
expect(info.method).toBe('reconnect');
expect(info.injected).toBe(true);
expect(info.status).toBe('connecting');
});
it('keeps loading pending until asynchronous component loading completes', async () => {
let finishLoading!: (result: {
method: 'loadModuleManual';
handle: ManualMapHandle;
}) => void;
const loading = new Promise<{ method: 'loadModuleManual'; handle: ManualMapHandle }>(
(resolve) => {
finishLoading = resolve;
},
);
const injector = {
inject: vi.fn(async () => loading),
unload: vi.fn(async () => {}),
};
const session = new HookSession(1234, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
const load = session.load();
await flush();
expect(session.status).toBe('loading');
finishLoading({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await expect(load).resolves.toMatchObject({
status: 'connecting',
method: 'loadModuleManual',
injected: true,
});
});
it('inject failure → status error, error message captured', async () => {
const injector = {
inject: vi.fn(async () => {
throw new Error('inject boom');
}),
unload: vi.fn(async () => {}),
};
const session = new HookSession(1234, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
const info = await session.load();
expect(info.status).toBe('error');
expect(info.error).toBe('inject boom');
});
it('can retry after asynchronous component loading fails', async () => {
const injector = {
inject: vi
.fn()
.mockRejectedValueOnce(new Error('component loading timed out'))
.mockResolvedValueOnce({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
}),
unload: vi.fn(async () => {}),
};
const session = new HookSession(1234, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
await expect(session.load()).resolves.toMatchObject({
status: 'error',
error: 'component loading timed out',
injected: false,
});
await expect(session.load()).resolves.toMatchObject({
status: 'connecting',
method: 'loadModuleManual',
injected: true,
error: '',
});
expect(injector.inject).toHaveBeenCalledTimes(2);
});
});
describe('HookSession — initial connection deadline', () => {
const timeoutMs = 60_000;
const sessions: HookSession[] = [];
function setup(opts: Parameters<typeof makeSession>[0] = {}) {
const ctx = makeSession(opts);
sessions.push(ctx.session);
return ctx;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
});
afterEach(() => {
for (const session of sessions.splice(0)) {
session.dispose();
}
vi.useRealTimers();
});
it('reports a missing channel without losing the loaded component or clearing the error on refresh', async () => {
const { session } = setup();
const changed = vi.fn();
session.on('status-changed', changed);
await expect(session.load()).resolves.toMatchObject({ status: 'connecting' });
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
expect(session.status).toBe('connecting');
await vi.advanceTimersByTimeAsync(1);
expect(session.toInfo()).toMatchObject({
status: 'error',
injected: true,
connected: false,
error: expect.stringContaining('首次连接超时'),
});
const error = session.error;
expect(changed).toHaveBeenLastCalledWith('error', error);
session.onPipeDown();
await expect(session.refresh()).resolves.toMatchObject({ status: 'error', error });
});
it('unloads with the original handle after a timeout, including a failed unload retry', async () => {
const { session, injector } = setup();
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
expect(session.status).toBe('error');
injector.unload.mockRejectedValueOnce(new Error('unload rejected'));
await expect(session.unload()).resolves.toMatchObject({
status: 'error',
injected: true,
error: 'unload rejected',
});
await expect(session.unload()).resolves.toMatchObject({
status: 'available',
injected: false,
error: '',
});
expect(injector.unload).toHaveBeenNthCalledWith(1, session.pid, DUMMY_HANDLE);
expect(injector.unload).toHaveBeenNthCalledWith(2, session.pid, DUMMY_HANDLE);
await vi.advanceTimersByTimeAsync(timeoutMs);
expect(session.status).toBe('available');
});
it('retries the connection after timeout without loading the component twice', async () => {
const { session, injector, setPipeLive } = setup();
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
expect(session.status).toBe('error');
await expect(session.load()).resolves.toMatchObject({
status: 'connecting',
injected: true,
error: '',
});
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
expect(session.status).toBe('connecting');
setPipeLive(true);
session.onPipeUp();
await flush();
expect(session.toInfo()).toMatchObject({ status: 'loaded', error: '', loggedIn: false });
expect(injector.inject).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(timeoutMs * 2);
expect(session.status).toBe('loaded');
});
it('can time out again after an explicit retry', async () => {
const { session, injector } = setup();
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
expect(session.status).toBe('error');
expect(session.error).toContain('首次连接超时');
expect(injector.inject).toHaveBeenCalledOnce();
});
it('accepts a late channel without a manual retry and retains the timeout across failed handshakes', async () => {
let failConnect = true;
const { session, injector, setPipeLive } = setup({
connectClient: async () => {
if (failConnect) {
throw new Error('connect failed');
}
},
});
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
const error = session.error;
expect(error).toContain('首次连接超时');
setPipeLive(true);
session.onPipeUp();
await flush();
expect(session.toInfo()).toMatchObject({ status: 'error', error });
failConnect = false;
session.onPipeUp();
await flush();
expect(session.toInfo()).toMatchObject({ status: 'loaded', error: '', connected: true });
expect(injector.inject).toHaveBeenCalledOnce();
});
it('bounds a stalled handshake by the remaining initial budget and permits unload before it finishes', async () => {
let finishConnect!: () => void;
const { session, currentClient, injector, setPipeLive } = setup({
connectClient: () =>
new Promise<void>((resolve) => {
finishConnect = resolve;
}),
});
const login = vi.fn();
const disconnected = vi.fn();
session.on('login', login);
session.on('disconnected', disconnected);
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs - 1000);
setPipeLive(true);
session.onPipeUp();
await flush();
currentClient().fireLogin('10001');
// Login and packets can arrive together before connectAll settles.
// Preserve their ordering, but revoke this binding if the handshake fails.
expect(login).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1000);
expect(session.toInfo()).toMatchObject({
status: 'error',
connected: false,
loggedIn: false,
});
expect(session.error).toContain('首次连接超时');
expect(currentClient().isClosed).toBe(true);
expect(disconnected).toHaveBeenCalledExactlyOnceWith(true);
setPipeLive(false);
await expect(session.unload()).resolves.toMatchObject({
status: 'available',
injected: false,
});
expect(injector.unload).toHaveBeenCalledExactlyOnceWith(session.pid, DUMMY_HANDLE);
finishConnect();
await flush();
expect(session.toInfo()).toMatchObject({
status: 'available',
connected: false,
loggedIn: false,
});
expect(login).toHaveBeenCalledOnce();
});
it('coalesces watcher retries so they cannot queue ahead of unload after a stalled handshake', async () => {
const { session, injector, clients } = setup({
connectClient: () => new Promise<void>(() => {}),
});
await session.load();
session.onPipeUp();
await flush();
for (let tick = 0; tick < 40; tick++) {
session.onPipeUp();
}
await vi.advanceTimersByTimeAsync(timeoutMs);
let result: Awaited<ReturnType<HookSession['unload']>> | undefined;
const unloading = session.unload().then((info) => {
result = info;
});
await flush();
expect(result).toMatchObject({ status: 'available', injected: false });
expect(clients).toHaveLength(1);
expect(injector.unload).toHaveBeenCalledExactlyOnceWith(session.pid, DUMMY_HANDLE);
await unloading;
});
it('starts the deadline after loading completes and repeated load calls do not postpone it', async () => {
const { session, injector } = setup();
let finishLoading!: (value: {
method: 'loadModuleManual';
handle: ManualMapHandle;
}) => void;
injector.inject.mockReturnValueOnce(
new Promise((resolve) => {
finishLoading = resolve;
}),
);
const loading = session.load();
await flush();
await vi.advanceTimersByTimeAsync(timeoutMs * 2);
expect(session.status).toBe('loading');
finishLoading({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await loading;
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
await session.load();
expect(session.status).toBe('connecting');
await vi.advanceTimersByTimeAsync(1);
expect(session.status).toBe('error');
expect(injector.inject).toHaveBeenCalledOnce();
});
it('unloads immediately if a late connection attempt is still handshaking after timeout', async () => {
const { session, currentClient, injector } = setup({
connectClient: () => new Promise<void>(() => {}),
});
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs);
session.onPipeUp();
await flush();
let result: Awaited<ReturnType<HookSession['unload']>> | undefined;
const unloading = session.unload().then((info) => {
result = info;
});
await flush();
expect(result).toMatchObject({ status: 'available', injected: false });
expect(currentClient().isClosed).toBe(true);
expect(injector.unload).toHaveBeenCalledExactlyOnceWith(session.pid, DUMMY_HANDLE);
expect(vi.getTimerCount()).toBe(0);
await unloading;
});
it('cancels the deadline on unload and gives a new load a full budget', async () => {
const { session, injector } = setup();
await session.load();
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
await session.unload();
await session.load();
await vi.advanceTimersByTimeAsync(1);
expect(session.status).toBe('connecting');
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
expect(session.status).toBe('error');
expect(injector.inject).toHaveBeenCalledTimes(2);
});
it.each(['dispose', 'process-gone'] as const)(
'cancels a pending deadline on %s',
async (event) => {
const { session } = setup();
const changed = vi.fn();
await session.load();
session.on('status-changed', changed);
if (event === 'dispose') {
session.dispose();
} else {
session.notifyProcessGone();
await flush();
}
changed.mockClear();
await vi.advanceTimersByTimeAsync(timeoutMs * 2);
expect(changed).not.toHaveBeenCalled();
expect(session.error).toBe('');
expect(vi.getTimerCount()).toBe(0);
},
);
it('does not let a disposed connection finish or retain its timer', async () => {
let finishConnect!: () => void;
const { session, currentClient } = setup({
pipeLive: true,
connectClient: () =>
new Promise<void>((resolve) => {
finishConnect = resolve;
}),
});
await session.load();
session.onPipeUp();
await flush();
session.dispose();
await flush();
expect(currentClient().isClosed).toBe(true);
expect(vi.getTimerCount()).toBe(0);
finishConnect();
await flush();
expect(session.toInfo()).toMatchObject({ connected: false, loggedIn: false });
});
it('does not use the initial deadline as a login or later disconnection timeout', async () => {
const { session, setPipeLive } = setup({ pipeLive: true });
session.onPipeUp();
await flush();
await vi.advanceTimersByTimeAsync(timeoutMs * 2);
expect(session.toInfo()).toMatchObject({ status: 'loaded', loggedIn: false, error: '' });
setPipeLive(false);
session.onPipeDown();
await flush();
await vi.advanceTimersByTimeAsync(timeoutMs * 2);
expect(session.toInfo()).toMatchObject({ status: 'connecting', error: '' });
});
});
describe('HookSession — pipe up/down → connect lifecycle', () => {
it('onPipeUp after load: client connects → status loaded', async () => {
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
expect(ctx.session.status).toBe('loaded');
expect(ctx.clients).toHaveLength(1);
});
it('emits "login" with (uin, sender) after client signals loggedIn', async () => {
const ctx = makeSession({ pipeLive: true });
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
expect(loginSpy).toHaveBeenCalledOnce();
const [uin, sender] = loginSpy.mock.calls[0]!;
expect(uin).toBe('10001');
expect(typeof (sender as { sendPacket: unknown }).sendPacket).toBe('function');
expect(ctx.session.status).toBe('online');
expect(ctx.session.uin).toBe('10001');
});
it('does not re-emit "login" on duplicate loginState with same uin', async () => {
const ctx = makeSession({ pipeLive: true });
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.currentClient().fireLogin('10001');
expect(loginSpy).toHaveBeenCalledOnce();
});
it('connect failure: stays in connecting, error message captured (will retry on next tick)', async () => {
const ctx = makeSession({ pipeLive: true, clientFailsConnect: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
expect(ctx.session.status).toBe('connecting');
expect(ctx.session.error).toBe('connect failed');
});
it('onPipeDown while logged in: emits disconnected(true), status disconnected', async () => {
const ctx = makeSession({ pipeLive: true });
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.session.onPipeDown();
await flush();
expect(discSpy).toHaveBeenCalledWith(true);
expect(ctx.session.status).toBe('disconnected');
});
it('onPipeDown while connected-but-not-logged-in: emits disconnected(false), status connecting', async () => {
const ctx = makeSession({ pipeLive: true });
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
// No login fired — we're connected but not logged in.
ctx.session.onPipeDown();
await flush();
// disconnected(false) is currently NOT emitted — disconnect events are
// reserved for "we owe BridgeManager a disconnect notification". This
// preserves the original behaviour.
expect(discSpy).not.toHaveBeenCalled();
expect(ctx.session.status).toBe('connecting');
});
it('repeated pipe down → up cycles reach consistent state', async () => {
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
expect(ctx.session.status).toBe('online');
ctx.session.onPipeDown();
await flush();
expect(ctx.session.status).toBe('disconnected');
// Pipe returns — session builds a fresh client.
ctx.session.onPipeUp();
await flush();
expect(ctx.clients.length).toBeGreaterThan(1);
expect(ctx.session.status).toBe('loaded');
});
});
describe('HookSession — unload', () => {
it('while logged in: emits disconnected(true), calls unload, status available', async () => {
const ctx = makeSession({ pipeLive: false });
await ctx.session.load();
ctx.setPipeLive(true);
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
ctx.setPipeLive(false); // unload verification will see the pipe gone
await ctx.session.unload();
expect(discSpy).toHaveBeenCalledWith(true);
expect(ctx.injector.unload).toHaveBeenCalledOnce();
expect(ctx.session.status).toBe('available');
});
it('when pipe stays live after unload: status connecting with retry message', async () => {
const ctx = makeSession({ pipeLive: false });
await ctx.session.load();
// Simulate the unload-failed scenario: pipe is still up after unload.
ctx.setPipeLive(true);
const info = await ctx.session.unload();
expect(info.status).toBe('connecting');
expect(info.error).toContain('命名管道仍然存在');
});
it('forces a fresh tick before verifying so a stale snapshot does not false-flag a successful unload', async () => {
// Reproduces the Windows-WebUI bug: PipeWatcher's cached snapshot is
// up to 1500ms stale, so right after a successful native unload the
// pre-fix code would read isPipeLive() == true and report failure
// even though the DLL is gone. With the fix, tickNow() refreshes
// the snapshot first and verification correctly sees the pipe down.
const pid = 5555;
let pipeLive = true; // initial cached state: pipe is live
const tickNow = vi.fn(async () => {
pipeLive = false;
}); // OS poll says: gone now
const session = new HookSession(pid, {
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => pipeLive, tickNow },
});
await session.load();
pipeLive = true; // ensure the pre-check snapshot says "live"
const info = await session.unload();
expect(tickNow).toHaveBeenCalledOnce();
expect(info.status).toBe('available');
expect(info.error).toBe('');
});
});
describe('HookSession — serialization', () => {
it('waits for native unload before starting a queued load', async () => {
const ctx = makeSession();
await ctx.session.load();
let finish!: () => void;
ctx.injector.unload.mockReturnValueOnce(
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const unloading = ctx.session.unload();
const loading = ctx.session.load();
await flush();
expect(ctx.injector.unload).toHaveBeenCalledOnce();
expect(ctx.injector.inject).toHaveBeenCalledTimes(1);
expect(ctx.session.toInfo().injected).toBe(true);
finish();
await unloading;
await loading;
expect(ctx.injector.inject).toHaveBeenCalledTimes(2);
ctx.session.dispose();
});
it('retains the handle when native unload rejects so it can be retried', async () => {
const ctx = makeSession();
await ctx.session.load();
ctx.injector.unload.mockRejectedValueOnce(new Error('unload rejected'));
await expect(ctx.session.unload()).resolves.toMatchObject({
status: 'error',
error: 'unload rejected',
injected: true,
});
await expect(ctx.session.unload()).resolves.toMatchObject({
status: 'available',
injected: false,
});
expect(ctx.injector.unload).toHaveBeenNthCalledWith(2, ctx.session.pid, DUMMY_HANDLE);
ctx.session.dispose();
});
it('user mashing load → unload → load runs in order, end state consistent', async () => {
const ctx = makeSession({ pipeLive: false });
const p1 = ctx.session.load();
const p2 = ctx.session.unload();
const p3 = ctx.session.load();
await Promise.all([p1, p2, p3]);
expect(ctx.injector.inject).toHaveBeenCalledTimes(2);
expect(ctx.injector.unload).toHaveBeenCalledTimes(1);
expect(ctx.session.status).toBe('connecting');
});
it('onPipeUp queued behind in-flight load resolves correctly', async () => {
const ctx = makeSession({ pipeLive: true });
const loadPromise = ctx.session.load();
ctx.session.onPipeUp(); // queued behind load
await loadPromise;
await flush();
expect(ctx.session.status).toBe('loaded');
});
});
describe('HookSession — process gone', () => {
it('emits disconnected(true) then disposed when killed while logged in', async () => {
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
const events: string[] = [];
ctx.session.on('disconnected', () => events.push('disconnected'));
ctx.session.on('disposed', () => events.push('disposed'));
ctx.session.notifyProcessGone();
await flush();
expect(events).toEqual(['disconnected', 'disposed']);
expect(ctx.session.isDisposed).toBe(true);
});
it('emits only disposed when killed before login', async () => {
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
const events: string[] = [];
ctx.session.on('disconnected', () => events.push('disconnected'));
ctx.session.on('disposed', () => events.push('disposed'));
ctx.session.notifyProcessGone();
await flush();
expect(events).toEqual(['disposed']);
});
});
describe('HookSession — dispose', () => {
it('emits disconnected(true) while logged in so BridgeManager can detach the PID', async () => {
const ctx = makeSession({ pipeLive: true });
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.session.dispose();
expect(discSpy).toHaveBeenCalledOnce();
expect(discSpy).toHaveBeenCalledWith(true);
expect(ctx.session.isDisposed).toBe(true);
});
it('does not emit disconnected when disposed before login', async () => {
const ctx = makeSession({ pipeLive: true });
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.session.dispose();
expect(discSpy).not.toHaveBeenCalled();
expect(ctx.session.isDisposed).toBe(true);
});
});
describe('HookSession — refresh drift fix', () => {
// refresh while the pipe is down on a connected-but-never-logged-in
// session now reports 'connecting' (matching what onPipeDown gives for the
// same state), not the old spurious 'disconnected'. Pre-refactor, refresh's
// down branch ignored wasLoggedIn and always reported 'disconnected'.
it('refresh-while-down + never logged in → connecting (not disconnected), no disconnect emit', async () => {
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
expect(ctx.session.status).toBe('loaded'); // connected, never logged in
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
ctx.setPipeLive(false);
const info = await ctx.session.refresh();
expect(info.status).toBe('connecting'); // drift fix: was 'disconnected'
expect(discSpy).not.toHaveBeenCalled(); // never logged in ⇒ nothing owed
});
it('refresh-while-down after login still → disconnected + emits disconnected(true)', async () => {
// The flip is scoped to the never-logged-in case; a real session that
// had reached login must still settle to 'disconnected' and notify.
const ctx = makeSession({ pipeLive: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
expect(ctx.session.status).toBe('online');
const discSpy = vi.fn();
ctx.session.on('disconnected', discSpy);
ctx.setPipeLive(false);
const info = await ctx.session.refresh();
expect(info.status).toBe('disconnected');
expect(discSpy).toHaveBeenCalledWith(true);
});
});
describe('HookSession — pipe-down must not clobber a settled error', () => {
// Regression net for the phase-2 reconcilePipeDown !connected short-circuit:
// a pipe-down tick on a session that never reached a live connection must
// not wipe a diagnostic error or emit a spurious status-changed.
it('onPipeDown after a failed connect keeps the error and does not re-emit', async () => {
const ctx = makeSession({ pipeLive: true, clientFailsConnect: true });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
expect(ctx.session.status).toBe('connecting');
expect(ctx.session.error).toBe('connect failed');
const statusSpy = vi.fn();
ctx.session.on('status-changed', statusSpy);
ctx.session.onPipeDown();
await flush();
expect(ctx.session.status).toBe('connecting');
expect(ctx.session.error).toBe('connect failed'); // not wiped to ''
expect(statusSpy).not.toHaveBeenCalled(); // no spurious emit
});
it('onPipeDown after a failed load leaves the error status intact', async () => {
const injector = {
inject: vi.fn(async () => {
throw new Error('inject boom');
}),
unload: vi.fn(async () => {}),
};
const session = new HookSession(1234, {
injector,
makeClient: () => new FakeClient() as unknown as QqHookClient,
pipeWatcher: { isPipeLive: () => false },
});
await session.load();
expect(session.status).toBe('error');
expect(session.error).toBe('inject boom');
session.onPipeDown();
await flush();
expect(session.status).toBe('error'); // not flipped to 'available'
expect(session.error).toBe('inject boom'); // not wiped
});
});
describe('HookSession — packet forwarding', () => {
it('forwards client receipts only for the active account without changing direction', async () => {
const onPacket = vi.fn();
const ctx = makeSession({ pipeLive: true, onPacket });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
const receipt: QqHookPacket = {
seq: 42,
error: 0,
cmd: 'ClientMessageReceipt',
uin: '10001',
source: 'client-message',
body: Buffer.from([1, 2, 3]),
};
for (const uin of ['', '0', '20002']) {
ctx.currentClient().firePacket({ ...receipt, uin });
}
expect(onPacket).not.toHaveBeenCalled();
ctx.currentClient().firePacket(receipt);
expect(onPacket).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
pid: 1234,
uin: '10001',
source: 'client-message',
fromClient: false,
}),
);
ctx.currentClient().fireLogin('20002');
ctx.currentClient().firePacket(receipt);
expect(onPacket).toHaveBeenCalledOnce();
await ctx.session.dispose();
});
// These tests cover the field-rename + filter logic that used to live
// inside the deleted NtqqHandler.onHookPacket. Inlining it here means
// we can exercise the transformation with a plain vi.fn() sink, no
// event-emitter machinery required.
it('forwards parsed packets to onPacket sink with PacketInfo shape', async () => {
const onPacket = vi.fn();
const ctx = makeSession({ pipeLive: true, onPacket });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.currentClient().firePacket({
seq: 42,
error: 0,
cmd: 'trpc.msg.olpush.OlPushService.MsgPush',
uin: '10001',
body: Buffer.from([0x01, 0x02, 0x03]),
});
expect(onPacket).toHaveBeenCalledOnce();
const pkt = onPacket.mock.calls[0]![0];
expect(pkt).toMatchObject({
pid: 1234,
uin: '10001',
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
seqId: 42,
retCode: 0,
fromClient: false,
});
expect(Buffer.isBuffer(pkt.body)).toBe(true);
expect([...pkt.body]).toEqual([1, 2, 3]);
});
it('falls back to session uin when packet.uin is empty', async () => {
const onPacket = vi.fn();
const ctx = makeSession({ pipeLive: true, onPacket });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
ctx.currentClient().firePacket({
seq: 1,
error: 0,
cmd: 'foo',
uin: '',
body: Buffer.alloc(0),
});
expect(onPacket.mock.calls[0]![0].uin).toBe('10001');
});
it('drops packets received before login', async () => {
const onPacket = vi.fn();
const ctx = makeSession({ pipeLive: true, onPacket });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
// No login fired yet.
ctx.currentClient().firePacket({
seq: 1,
error: 0,
cmd: 'foo',
uin: '10001',
body: Buffer.alloc(0),
});
expect(onPacket).not.toHaveBeenCalled();
});
it('drops packets whose uin is non-real ("0" / too short / non-numeric)', async () => {
const onPacket = vi.fn();
const ctx = makeSession({ pipeLive: true, onPacket });
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
// Packet uin '0' falls back to session uin '10001' — that one passes.
// Force a bad uin via the packet AND null out session uin by simulating
// a logout? Simpler: send a packet with uin '42' (numeric but <5 chars).
ctx.currentClient().firePacket({
seq: 1,
error: 0,
cmd: 'foo',
uin: '42',
body: Buffer.alloc(0),
});
expect(onPacket).not.toHaveBeenCalled();
});
it('is a no-op when no onPacket sink is provided', async () => {
const ctx = makeSession({ pipeLive: true }); // no onPacket
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
// Should not throw / crash.
ctx.currentClient().firePacket({
seq: 1,
error: 0,
cmd: 'foo',
uin: '10001',
body: Buffer.from([7]),
});
});
});
describe('HookSession — login reconcile (Docker auto-login safety net)', () => {
it.each([
{ uin: '1700000000000', uinNumber: 1700000000000n },
{ uin: 'not-an-account', uinNumber: 0n },
{ uin: '0', uinNumber: 0n },
{ uin: '', uinNumber: 1700000000000n },
])(
'reconciles a rejected login identity received during connection: $uin',
async (identity) => {
vi.useFakeTimers();
setLogLevel('warn');
const entries: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const detectLoginUin = vi.fn(async () => '10001');
const onPacket = vi.fn<PacketSink>();
const state = { loggedIn: true, ...identity };
const ctx = makeSession({
pipeLive: true,
detectLoginUin,
onPacket,
connectClient: async (client) => client.reconcileLoginState(state),
});
const login = vi.fn();
ctx.session.on('login', login);
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
expect(ctx.session.toInfo()).toMatchObject({
connected: true,
loggedIn: false,
status: 'loaded',
uin: '0',
});
expect(login).not.toHaveBeenCalled();
const packet = {
seq: 1,
error: 0,
cmd: 'Test.Message',
uin: '10001',
body: Buffer.alloc(0),
};
ctx.currentClient().firePacket(packet);
expect(onPacket).not.toHaveBeenCalled();
ctx.currentClient().reconcileLoginState(state);
await vi.advanceTimersByTimeAsync(9000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
expect(
entries
.filter((entry) => entry.scope === 'HookSession' && entry.level === 'warn')
.map((entry) => entry.message),
).toEqual(['Login readiness rejected: PID=1234 reason=invalid_account_identity']);
ctx.currentClient().fireLogin('10001');
ctx.currentClient().firePacket(packet);
expect(login).toHaveBeenCalledOnce();
expect(onPacket).toHaveBeenCalledOnce();
expect(ctx.session.toInfo()).toMatchObject({
loggedIn: true,
status: 'online',
uin: '10001',
});
const detections = detectLoginUin.mock.calls.length;
await vi.advanceTimersByTimeAsync(30_000);
expect(detectLoginUin).toHaveBeenCalledTimes(detections);
} finally {
ctx.session.dispose();
unsubscribe();
vi.useRealTimers();
}
},
);
it('revokes a ready account and starts a fresh identity check when login readiness is lost', async () => {
vi.useFakeTimers();
const ctx = makeSession({ pipeLive: true, detectLoginUin: async () => '10001' });
const login = vi.fn();
const disconnected = vi.fn();
ctx.session.on('login', login);
ctx.session.on('disconnected', disconnected);
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
ctx.currentClient().fireLogin('10001');
expect(login).toHaveBeenCalledOnce();
ctx.currentClient().fireLogin('1700000000000');
ctx.currentClient().fireLogin('1700000000001');
await vi.advanceTimersByTimeAsync(0);
expect(disconnected).toHaveBeenCalledExactlyOnceWith(true);
expect(ctx.session.toInfo()).toMatchObject({
status: 'loaded',
loggedIn: false,
uin: '0',
});
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001', '10001']);
expect(login).toHaveBeenCalledOnce();
ctx.currentClient().fireLogin('10001');
expect(login).toHaveBeenCalledTimes(2);
expect(ctx.session.status).toBe('online');
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('does not probe a rejected login identity before a stalled connection times out', async () => {
vi.useFakeTimers();
const handshake = Promise.withResolvers<void>();
const detectLoginUin = vi.fn(async () => '10001');
const ctx = makeSession({ detectLoginUin, connectClient: () => handshake.promise });
const login = vi.fn();
ctx.session.on('login', login);
try {
await ctx.session.load();
ctx.setPipeLive(true);
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
const previous = ctx.currentClient();
previous.fireLogin('1700000000000');
await vi.advanceTimersByTimeAsync(60_000);
expect(ctx.session.toInfo()).toMatchObject({
status: 'error',
connected: false,
loggedIn: false,
});
expect(detectLoginUin).not.toHaveBeenCalled();
expect(previous.isClosed).toBe(true);
expect(vi.getTimerCount()).toBe(0);
previous.fireLogin('10001');
handshake.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(login).not.toHaveBeenCalled();
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient()).not.toBe(previous);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
expect(ctx.session.toInfo()).toMatchObject({
status: 'loaded',
connected: true,
loggedIn: false,
});
} finally {
handshake.resolve();
ctx.session.dispose();
vi.useRealTimers();
}
});
it('discards an identity acknowledgement from before login readiness was lost', async () => {
vi.useFakeTimers();
const hint = Promise.withResolvers<void>();
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '10001',
connectClient: async (client) => {
vi.spyOn(client, 'reconcileLoginIdentity').mockImplementationOnce(async (uin) => {
client.loginIdentityHints.push(uin);
await hint.promise;
});
},
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
ctx.currentClient().fireLogin('10001');
ctx.currentClient().fireLogin('1700000000000');
hint.resolve();
await vi.advanceTimersByTimeAsync(3000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001', '10001']);
expect(ctx.session.toInfo()).toMatchObject({ status: 'loaded', loggedIn: false });
} finally {
hint.resolve();
ctx.session.dispose();
vi.useRealTimers();
}
});
it('discards an account observation from before login readiness was lost', async () => {
vi.useFakeTimers();
const observation = Promise.withResolvers<string | null>();
const detectLoginUin = vi.fn<() => Promise<string | null>>().mockResolvedValue('20002');
detectLoginUin.mockReturnValueOnce(observation.promise);
const ctx = makeSession({ pipeLive: true, detectLoginUin });
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
ctx.currentClient().fireLogin('10001');
ctx.currentClient().fireLogin('1700000000000');
observation.resolve('10001');
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual([]);
await vi.advanceTimersByTimeAsync(3000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['20002']);
} finally {
observation.resolve(null);
ctx.session.dispose();
vi.useRealTimers();
}
});
it.each(['observation', 'hint'] as const)(
'ignores a late %s failure after login readiness is confirmed',
async (phase) => {
vi.useFakeTimers();
setLogLevel('trace');
const entries: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const pending = Promise.withResolvers<void>();
const detectLoginUin = vi.fn(async () => {
if (phase === 'observation') {
await pending.promise;
}
return '10001';
});
const ctx = makeSession({
pipeLive: true,
detectLoginUin,
connectClient: async (client) => {
if (phase === 'hint') {
vi.spyOn(client, 'reconcileLoginIdentity').mockImplementationOnce(
async (uin) => {
client.loginIdentityHints.push(uin);
await pending.promise;
},
);
}
},
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(detectLoginUin).toHaveBeenCalledOnce();
expect(ctx.currentClient().loginIdentityHints).toEqual(
phase === 'hint' ? ['10001'] : [],
);
ctx.currentClient().fireLogin('10001');
entries.length = 0;
pending.reject(new Error('expired identity request'));
await vi.advanceTimersByTimeAsync(30_000);
expect(
entries.some((entry) => entry.message.includes('expired identity request')),
).toBe(false);
expect(detectLoginUin).toHaveBeenCalledOnce();
expect(ctx.session.toInfo()).toMatchObject({
status: 'online',
loggedIn: true,
uin: '10001',
});
} finally {
pending.resolve();
ctx.session.dispose();
unsubscribe();
vi.useRealTimers();
}
},
);
it('clears the login check timer when identity detection disposes the session synchronously', async () => {
vi.useFakeTimers();
const detectLoginUin = vi.fn<() => Promise<string | null>>();
const ctx = makeSession({ pipeLive: true, detectLoginUin });
detectLoginUin.mockImplementation(async () => {
ctx.session.dispose();
return null;
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.session.isDisposed).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('discards an identity read started before a pipe reconnect', async () => {
const detection = Promise.withResolvers<string | null>();
const ctx = makeSession({ pipeLive: true, detectLoginUin: () => detection.promise });
try {
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
const previous = ctx.currentClient();
previous.close();
await flush();
ctx.session.onPipeUp();
await flush();
const current = ctx.currentClient();
expect(current).not.toBe(previous);
detection.resolve('10001');
await flush();
expect(current.loginIdentityHints).toEqual([]);
expect(previous.loginIdentityHints).toEqual([]);
} finally {
detection.resolve(null);
ctx.session.dispose();
}
});
it('keeps the packet sender usable only after native readiness confirms the identity hint', async () => {
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '10001',
nativeReadyOnIdentityHint: true,
});
let sender: PacketSender | undefined;
ctx.session.on('login', (_uin: string, nextSender: PacketSender) => {
sender = nextSender;
});
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
await flush();
expect(sender).toBeDefined();
const result = await sender!.sendPacket('Test.LoginReady', Buffer.from('request'));
expect(result).toMatchObject({
success: true,
gotResponse: true,
errorCode: 0,
responseData: Buffer.from('reply'),
});
expect(ctx.currentClient().sentPackets).toEqual([
{ serviceCmd: 'Test.LoginReady', body: Buffer.from('request') },
]);
});
it('treats a locally detected account as an identity hint, not native login proof', async () => {
// Account identity does not prove request readiness. The bridge must wait
// for a login-state confirmation instead of synthesizing one.
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '10001',
});
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush(); // connect (not logged in via frame) → starts reconcile
await flush(); // immediate probe resolves → forwards identity hint
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
expect(loginSpy).not.toHaveBeenCalled();
expect(ctx.session.status).toBe('loaded');
expect(ctx.session.uin).toBe('0');
});
it('forwards the same identity at most once per pipe connection', async () => {
vi.useFakeTimers();
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '10001',
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(9000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('keeps observing identity after acknowledgement and forwards an account change', async () => {
vi.useFakeTimers();
let uin = '10001';
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => uin,
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
uin = '20002';
await vi.advanceTimersByTimeAsync(29_999);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
await vi.advanceTimersByTimeAsync(1);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001', '20002']);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('retries identity forwarding failures with a strict bound', async () => {
vi.useFakeTimers();
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '10001',
identityHintFailures: 3,
});
try {
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001']);
await vi.advanceTimersByTimeAsync(3000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001', '10001']);
await vi.advanceTimersByTimeAsync(9000);
expect(ctx.currentClient().loginIdentityHints).toEqual(['10001', '10001', '10001']);
await vi.advanceTimersByTimeAsync(30_000);
expect(ctx.currentClient().loginIdentityHints).toHaveLength(3);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('does not log in when no account is detected', async () => {
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => null,
});
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
await flush();
expect(loginSpy).not.toHaveBeenCalled();
expect(ctx.session.status).toBe('loaded');
});
it('ignores a detected account with a non-real uin (0)', async () => {
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => '0',
});
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
await flush();
expect(loginSpy).not.toHaveBeenCalled();
});
it('tolerates a throwing detection (best-effort, no crash/login)', async () => {
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => {
throw new Error('account detection unavailable');
},
});
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
await flush();
expect(loginSpy).not.toHaveBeenCalled();
expect(ctx.session.status).toBe('loaded');
});
it('detects a login that lands on a LATER interval check', async () => {
vi.useFakeTimers();
try {
let signedIn = false;
const ctx = makeSession({
pipeLive: true,
detectLoginUin: async () => (signedIn ? '10001' : null),
nativeReadyOnIdentityHint: true,
});
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0); // connect + immediate probe (not logged in yet)
expect(loginSpy).not.toHaveBeenCalled();
signedIn = true; // the account's database shows up as signed in
await vi.advanceTimersByTimeAsync(3000); // next interval check picks it up
expect(loginSpy).toHaveBeenCalledOnce();
expect(ctx.session.status).toBe('online');
} finally {
vi.useRealTimers();
}
});
it('does not double-emit when the pushed frame logs in first', async () => {
// Pushed-frame login wins; a subsequent probe with the same uin must not
// re-emit (handleLoginState dedups), and the reconcile timer is stopped.
const detectLoginUin = vi.fn(async () => '10001');
const ctx = makeSession({ pipeLive: true, detectLoginUin });
const loginSpy = vi.fn();
ctx.session.on('login', loginSpy);
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001'); // pushed-frame login first
await flush();
expect(loginSpy).toHaveBeenCalledOnce();
expect(ctx.session.status).toBe('online');
});
});
describe('HookSession — end-to-end receive health (#233)', () => {
const HEARTBEAT_CMD = 'trpc.qq_new_tech.status_svc.StatusService.SsoHeartBeat';
const MESSAGE_CMD = 'trpc.msg.olpush.OlPushService.MsgPush';
async function startReceiveSession(onPacket?: PacketSink) {
const ctx = makeSession({ pipeLive: true, onPacket });
const healthChanges: boolean[] = [];
ctx.session.on('receive-health-changed', (healthy: boolean) => healthChanges.push(healthy));
await ctx.session.load();
ctx.session.onPipeUp();
await flush();
ctx.currentClient().fireLogin('10001');
return { ctx, healthChanges };
}
function firePacket(
ctx: ReturnType<typeof makeSession>,
cmd: string,
uin = '10001',
seq = 1,
): void {
ctx.currentClient().firePacket({ seq, error: 0, cmd, uin, body: Buffer.alloc(0) });
}
it('reports native outbound loss as unhealthy and recovers on the next successful request', async () => {
const { ctx, healthChanges } = await startReceiveSession();
let sender: PacketSender | undefined;
ctx.session.on('login', (_uin: string, nextSender: PacketSender) => {
sender = nextSender;
});
try {
// Re-emit on a new account epoch so the test captures the session sender.
ctx.currentClient().fireLogin('20002');
ctx.currentClient().sendError = new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
1,
);
await sender!.sendPacket('Test.Command', Buffer.alloc(0));
expect(ctx.session.receiveHealthy).toBe(false);
expect(healthChanges).toEqual([false]);
// Incoming traffic alone must not conceal a still-broken send path.
firePacket(ctx, HEARTBEAT_CMD, '20002');
expect(ctx.session.receiveHealthy).toBe(false);
ctx.currentClient().sendError = null;
await sender!.sendPacket('Test.Command', Buffer.alloc(0));
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([false, true]);
} finally {
ctx.session.dispose();
}
});
it('becomes unhealthy only after an observed QQ heartbeat stays silent, then recovers on the next packet', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const { ctx, healthChanges } = await startReceiveSession();
try {
firePacket(ctx, HEARTBEAT_CMD);
vi.advanceTimersByTime(90_000);
expect(ctx.session.receiveHealthy).toBe(true);
vi.advanceTimersByTime(15_000);
expect(ctx.session.receiveHealthy).toBe(false);
expect(healthChanges).toEqual([false]);
firePacket(ctx, MESSAGE_CMD, '10001', 2);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([false, true]);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('does not arm the watchdog when this QQ session has never exposed its heartbeat', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const { ctx, healthChanges } = await startReceiveSession();
try {
firePacket(ctx, MESSAGE_CMD);
vi.advanceTimersByTime(10 * 60_000);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([]);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('uses the confirmation window to avoid a sleep-resume false alarm', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const { ctx, healthChanges } = await startReceiveSession();
try {
firePacket(ctx, HEARTBEAT_CMD);
vi.advanceTimersByTime(90_000);
vi.advanceTimersByTime(14_999);
firePacket(ctx, HEARTBEAT_CMD, '10001', 2);
vi.advanceTimersByTime(1);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([]);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('does not carry an armed watchdog across a direct account switch', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const { ctx, healthChanges } = await startReceiveSession();
try {
firePacket(ctx, HEARTBEAT_CMD);
ctx.currentClient().fireLogin('20002');
vi.advanceTimersByTime(10 * 60_000);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([]);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('requires the new account heartbeat after packet-driven PID migration', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const { ctx, healthChanges } = await startReceiveSession();
try {
firePacket(ctx, HEARTBEAT_CMD);
firePacket(ctx, MESSAGE_CMD, '20002', 2);
vi.advanceTimersByTime(10 * 60_000);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([]);
firePacket(ctx, HEARTBEAT_CMD, '20002', 3);
vi.advanceTimersByTime(105_000);
expect(ctx.session.receiveHealthy).toBe(false);
expect(healthChanges).toEqual([false]);
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
it('counts an invalid-UIN frame as receive activity without routing it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
const onPacket = vi.fn();
const { ctx, healthChanges } = await startReceiveSession(onPacket);
try {
firePacket(ctx, HEARTBEAT_CMD);
onPacket.mockClear();
vi.advanceTimersByTime(80_000);
firePacket(ctx, 'trpc.status.invalid-uin-probe', '0', 2);
vi.advanceTimersByTime(104_999);
expect(ctx.session.receiveHealthy).toBe(true);
expect(onPacket).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(ctx.session.receiveHealthy).toBe(false);
firePacket(ctx, 'trpc.status.invalid-uin-probe', '0', 3);
expect(ctx.session.receiveHealthy).toBe(true);
expect(healthChanges).toEqual([false, true]);
expect(onPacket).not.toHaveBeenCalled();
} finally {
ctx.session.dispose();
vi.useRealTimers();
}
});
});
describe('HookSession — login reconcile does not stack concurrent detections', () => {
it('skips overlapping ticks while a slow detection is in flight', async () => {
vi.useFakeTimers();
try {
// A detection that never resolves — simulates a slow read spanning
// multiple intervals. The in-flight guard must call it only once.
const detectLoginUin = vi.fn(
() =>
new Promise<never>(() => {
/* pending */
}),
);
const ctx = makeSession({ pipeLive: true, detectLoginUin });
await ctx.session.load();
ctx.session.onPipeUp();
await vi.advanceTimersByTimeAsync(0); // connect + immediate probe (starts, stays pending)
await vi.advanceTimersByTimeAsync(9000); // 3 more interval ticks
expect(detectLoginUin).toHaveBeenCalledTimes(1); // not stacked
} finally {
vi.useRealTimers();
}
});
});