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
2240 lines
82 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|