Files
SnowLuma/packages/bridge/tests/hook-session.test.ts
2026-10-12 15:43:34 +08:00

1443 lines
52 KiB
TypeScript

import { afterEach, 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 { QqPortLoginInfo } from '../src/qq-port-probe';
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;
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.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;
onPacket?: PacketSink;
probeLogin?: (pid: number) => Promise<QqPortLoginInfo | null>;
nativeReadyOnIdentityHint?: boolean;
identityHintFailures?: number;
} = {},
) {
const pid = opts.pid ?? 1234;
let pipeLive = opts.pipeLive ?? false;
const clients: FakeClient[] = [];
const injector = {
inject: vi.fn(() => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })),
unload: vi.fn(),
};
// Default probe exposes no identity so the reconcile safety net is inert
// unless a test opts into a known account.
const probeLogin =
opts.probeLogin ?? (async () => ({ port: 0, uin: '', identityKnown: false }));
const session = new HookSession(pid, {
injector,
makeClient: () => {
const c = new FakeClient();
if (opts.clientFailsConnect) {
c.shouldFailConnect = true;
}
c.nativeReadyOnIdentityHint = opts.nativeReadyOnIdentityHint ?? false;
c.identityHintFailures = opts.identityHintFailures ?? 0;
clients.push(c);
// Cast: FakeClient mirrors only the surface HookSession touches.
return c as unknown as QqHookClient;
},
probeLogin,
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 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(() => {
throw new Error('fixture inject failure');
}),
unload: vi.fn(),
};
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: () => {
pipeLive = true;
throw new Error('component loading failed [COMPONENT_LOAD_FAILED]');
},
unload: () => 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(() => loading),
unload: vi.fn(),
};
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(() => {
throw new Error('inject boom');
}),
unload: vi.fn(),
};
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(),
};
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 — 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(() => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(),
},
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('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(() => {
throw new Error('inject boom');
}),
unload: vi.fn(),
};
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', () => {
// 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('keeps the packet sender usable only after native readiness confirms the identity hint', async () => {
const ctx = makeSession({
pipeLive: true,
probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }),
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 port-probed 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,
probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }),
});
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,
probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }),
});
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,
probeLogin: async () => ({ port: 4301, uin, identityKnown: true }),
});
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,
probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }),
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 the probe reports not-logged-in', async () => {
const ctx = makeSession({
pipeLive: true,
probeLogin: async () => ({ port: 0, uin: '', identityKnown: false }),
});
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 probe with a non-real uin (0)', async () => {
const ctx = makeSession({
pipeLive: true,
probeLogin: async () => ({ port: 4301, uin: '0', identityKnown: true }),
});
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 probe (best-effort, no crash/login)', async () => {
const ctx = makeSession({
pipeLive: true,
probeLogin: async () => {
throw new Error('port scan failed');
},
});
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 probe', async () => {
vi.useFakeTimers();
try {
let identityKnown = false;
const ctx = makeSession({
pipeLive: true,
probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown }),
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();
identityKnown = true; // QQ exposes the restored identity
await vi.advanceTimersByTimeAsync(3000); // next interval probe 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 probeLogin = vi.fn(async () => ({ port: 4301, uin: '10001', identityKnown: true }));
const ctx = makeSession({ pipeLive: true, probeLogin });
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 probes', () => {
it('skips overlapping ticks while a slow probe is in flight', async () => {
vi.useFakeTimers();
try {
// A probe that never resolves — simulates a slow port scan spanning
// multiple intervals. The in-flight guard must call it only once.
const probeLogin = vi.fn(
() =>
new Promise<never>(() => {
/* pending */
}),
);
const ctx = makeSession({ pipeLive: true, probeLogin });
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(probeLogin).toHaveBeenCalledTimes(1); // not stacked
} finally {
vi.useRealTimers();
}
});
});