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
990 lines
38 KiB
TypeScript
990 lines
38 KiB
TypeScript
import { currentRequestId, runWithRequestId, subscribeLogs } from '@snowluma/common/logger';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { AdapterStatus } from '../src/network';
|
|
import type { OneBotInstance } from '../src/instance';
|
|
import {
|
|
OneBotManager,
|
|
parseSessionReleaseDeadlineMs,
|
|
resolveSessionReleaseDeadlineMs,
|
|
type DatabaseMigrationCallbacks,
|
|
type DatabaseMigrationTask,
|
|
} from '../src/manager';
|
|
import { makeDefaultOneBotConfig } from '../src/config';
|
|
import { estimateRemainingSeconds } from '../src/message-store-migration-task';
|
|
|
|
function fakeBridge(warmup?: Promise<{ friendsLoaded: boolean; groupsLoaded: boolean }>) {
|
|
return {
|
|
activePid: null,
|
|
identity: { nickname: 'test' },
|
|
whenRosterWarmupSettled: () =>
|
|
warmup ?? Promise.resolve({ friendsLoaded: false, groupsLoaded: false }),
|
|
};
|
|
}
|
|
|
|
function fakeInstance(
|
|
uin: string,
|
|
dispose: () => Promise<unknown>,
|
|
statuses: AdapterStatus[] = [],
|
|
quiesce: () => void = () => {},
|
|
): OneBotInstance {
|
|
return {
|
|
uin,
|
|
nickname: `retiring-${uin}`,
|
|
quiesce,
|
|
dispose,
|
|
emitBotStatus: vi.fn(async () => undefined),
|
|
getConnectionStatuses: () => statuses,
|
|
} as unknown as OneBotInstance;
|
|
}
|
|
|
|
describe('session release deadline', () => {
|
|
it('keeps 10 seconds when nothing overrides it', () => {
|
|
expect(resolveSessionReleaseDeadlineMs(undefined, undefined)).toBe(10_000);
|
|
expect(resolveSessionReleaseDeadlineMs(undefined, ' ')).toBe(10_000);
|
|
});
|
|
|
|
it('lets SNOWLUMA_SESSION_RELEASE_DEADLINE_MS override a configured deadline', () => {
|
|
expect(resolveSessionReleaseDeadlineMs(30, '2500')).toBe(2500);
|
|
expect(parseSessionReleaseDeadlineMs('0')).toBeUndefined();
|
|
expect(parseSessionReleaseDeadlineMs('-5')).toBeUndefined();
|
|
expect(parseSessionReleaseDeadlineMs('1.5')).toBeUndefined();
|
|
expect(parseSessionReleaseDeadlineMs('nope')).toBeUndefined();
|
|
});
|
|
|
|
it('ignores an unusable environment value and keeps the configured deadline', () => {
|
|
expect(resolveSessionReleaseDeadlineMs(30, 'nope')).toBe(30);
|
|
expect(resolveSessionReleaseDeadlineMs(undefined, 'nope')).toBe(10_000);
|
|
});
|
|
});
|
|
|
|
describe('database migration estimates', () => {
|
|
it('waits for measured throughput and reaches zero on completion', () => {
|
|
expect(
|
|
estimateRemainingSeconds({ phase: 'migrating', processed: 200, total: 1_000 }, null),
|
|
).toBeNull();
|
|
expect(
|
|
estimateRemainingSeconds({ phase: 'migrating', processed: 200, total: 1_000 }, 125),
|
|
).toBe(7);
|
|
expect(
|
|
estimateRemainingSeconds({ phase: 'complete', processed: 1_000, total: 1_000 }, null),
|
|
).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('OneBotManager database preparation', () => {
|
|
it('releases its session subscription before shutdown and cannot bind after disposal', async () => {
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
const source = {
|
|
subscribeSessions: vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second),
|
|
};
|
|
const manager = new OneBotManager();
|
|
manager.bind(source);
|
|
manager.bind(source);
|
|
expect(first).toHaveBeenCalledOnce();
|
|
expect(second).not.toHaveBeenCalled();
|
|
|
|
const disposed = manager.dispose();
|
|
expect(second).toHaveBeenCalledOnce();
|
|
await disposed;
|
|
await manager.dispose();
|
|
manager.bind(source);
|
|
|
|
expect(second).toHaveBeenCalledOnce();
|
|
expect(source.subscribeSessions).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each(['logout', 'dispose'] as const)(
|
|
'invalidates migration callbacks before cancelling the task on %s',
|
|
async (reason) => {
|
|
vi.useFakeTimers();
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const cancel = vi.fn(() => {
|
|
callbacks.onFailed(new Error('cancelled task'));
|
|
callbacks.onProgress({ phase: 'complete', processed: 1, total: 1 }, 100);
|
|
});
|
|
const createTask = vi.fn(() => ({
|
|
start: (next: DatabaseMigrationCallbacks) => {
|
|
callbacks = next;
|
|
},
|
|
beginMigration: vi.fn(),
|
|
cancel,
|
|
}));
|
|
const manager = new OneBotManager({ createDatabaseMigrationTask: createTask });
|
|
const lifecycle = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
|
|
try {
|
|
lifecycle.onSessionStarted('10001', fakeBridge() as never);
|
|
|
|
if (reason === 'logout') {
|
|
lifecycle.onSessionClosed('10001');
|
|
} else {
|
|
await manager.dispose();
|
|
}
|
|
|
|
expect(manager.getConnectionStatuses()).toEqual([]);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
vi.advanceTimersByTime(10_000);
|
|
expect(createTask).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await manager.dispose();
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it('detaches migration and network startup from the login operation', () => {
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const observedRequestIds: Array<number | undefined> = [];
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(() => observedRequestIds.push(currentRequestId())),
|
|
cancel: vi.fn(),
|
|
start: vi.fn((next) => {
|
|
observedRequestIds.push(currentRequestId());
|
|
callbacks = next;
|
|
}),
|
|
};
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(() => {
|
|
observedRequestIds.push(currentRequestId());
|
|
return new Promise(() => undefined);
|
|
}),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => task,
|
|
createInstance: () => {
|
|
observedRequestIds.push(currentRequestId());
|
|
return instance;
|
|
},
|
|
});
|
|
let startListener!: (uin: string, bridge: never) => void;
|
|
manager.bind({
|
|
subscribeSessions: (listener) => {
|
|
startListener = listener.started!;
|
|
return vi.fn();
|
|
},
|
|
});
|
|
const bridge = fakeBridge(new Promise(() => undefined));
|
|
|
|
runWithRequestId(5201, () => startListener('10001', bridge as never));
|
|
callbacks.onReady();
|
|
|
|
expect(observedRequestIds).toEqual([undefined, undefined, undefined, undefined]);
|
|
});
|
|
|
|
it('surfaces preparation as unavailable and ignores a stale completion after logout', async () => {
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const cancel = vi.fn();
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(),
|
|
cancel,
|
|
start: vi.fn((next) => {
|
|
callbacks = next;
|
|
}),
|
|
};
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => task,
|
|
createInstance: () => {
|
|
throw new Error('stale preparation started an account');
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
|
|
internals.onSessionStarted('10001', {} as never);
|
|
expect(manager.getConnectionStatuses()).toEqual([
|
|
{
|
|
uin: '10001',
|
|
nickname: '10001',
|
|
adapters: [],
|
|
databaseMigration: {
|
|
phase: 'preparing',
|
|
usable: false,
|
|
processed: 0,
|
|
total: null,
|
|
progress: null,
|
|
estimatedRemainingSeconds: null,
|
|
},
|
|
},
|
|
]);
|
|
|
|
internals.onSessionClosed('10001');
|
|
callbacks.onReady();
|
|
await Promise.resolve();
|
|
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(manager.getInstance('10001')).toBeNull();
|
|
expect(manager.getConnectionStatuses()).toEqual([]);
|
|
});
|
|
|
|
it('starts migration only after the account instance is available', async () => {
|
|
const originalCwd = process.cwd();
|
|
const root = mkdtempSync(path.join(tmpdir(), 'snowluma-manager-lifecycle-'));
|
|
process.chdir(root);
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
let instanceCreated = false;
|
|
const beginMigration = vi.fn(() => {
|
|
expect(instanceCreated).toBe(true);
|
|
});
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({
|
|
applied: true,
|
|
statuses: [],
|
|
errors: [],
|
|
})),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => ({
|
|
beginMigration,
|
|
cancel: vi.fn(),
|
|
start: (next) => {
|
|
callbacks = next;
|
|
},
|
|
}),
|
|
createInstance: (_uin, _bridge, _config, _globalSettings) => {
|
|
instanceCreated = true;
|
|
return instance;
|
|
},
|
|
});
|
|
|
|
try {
|
|
const bridge = fakeBridge();
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted('10001', bridge as never);
|
|
callbacks.onReady();
|
|
|
|
expect(beginMigration).toHaveBeenCalledOnce();
|
|
expect(manager.getInstance('10001')).not.toBeNull();
|
|
await vi.waitFor(() => {
|
|
expect(instance.emitBotStatus).toHaveBeenCalledWith('online');
|
|
});
|
|
} finally {
|
|
await manager.dispose();
|
|
process.chdir(originalCwd);
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('retries when starting the database task fails synchronously', () => {
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const tasks: DatabaseMigrationTask[] = [];
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => {
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: vi.fn(() => {
|
|
throw new Error('private worker detail');
|
|
}),
|
|
};
|
|
tasks.push(task);
|
|
return task;
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
const bridge = fakeBridge();
|
|
|
|
expect(() => internals.onSessionStarted('10001', bridge as never)).not.toThrow();
|
|
expect(tasks[0].cancel).toHaveBeenCalledOnce();
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toMatchObject({
|
|
phase: 'failed',
|
|
usable: false,
|
|
error: '数据库迁移失败:private worker detail;将自动重试',
|
|
});
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks[1].start).toHaveBeenCalledOnce();
|
|
internals.onSessionClosed('10001');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('keeps the account usable when beginning migration fails', () => {
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const tasks: DatabaseMigrationTask[] = [];
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({
|
|
applied: true,
|
|
statuses: [],
|
|
errors: [],
|
|
})),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => {
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(() => {
|
|
throw new Error('private signal detail');
|
|
}),
|
|
cancel: vi.fn(),
|
|
start: vi.fn((next) => {
|
|
callbacks = next;
|
|
}),
|
|
};
|
|
tasks.push(task);
|
|
return task;
|
|
},
|
|
createInstance: () => instance,
|
|
});
|
|
const internals = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
const bridge = fakeBridge();
|
|
|
|
internals.onSessionStarted('10001', bridge as never);
|
|
expect(() => callbacks.onReady()).not.toThrow();
|
|
expect(tasks[0].cancel).toHaveBeenCalledOnce();
|
|
expect(manager.getInstance('10001')).toBe(instance);
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toMatchObject({
|
|
phase: 'failed',
|
|
usable: true,
|
|
error: '数据库迁移失败:private signal detail;将自动重试',
|
|
});
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(tasks).toHaveLength(2);
|
|
internals.onSessionClosed('10001');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('retries when account startup fails after database preparation', () => {
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const callbacks: DatabaseMigrationCallbacks[] = [];
|
|
const tasks: DatabaseMigrationTask[] = [];
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => {
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: vi.fn((next) => {
|
|
callbacks.push(next);
|
|
}),
|
|
};
|
|
tasks.push(task);
|
|
return task;
|
|
},
|
|
createInstance: () => {
|
|
throw new Error('private startup detail');
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
const bridge = fakeBridge();
|
|
|
|
internals.onSessionStarted('10001', bridge as never);
|
|
expect(() => callbacks[0].onReady()).not.toThrow();
|
|
expect(tasks[0].cancel).toHaveBeenCalledOnce();
|
|
expect(tasks[0].beginMigration).not.toHaveBeenCalled();
|
|
expect(manager.getInstance('10001')).toBeNull();
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toMatchObject({
|
|
phase: 'failed',
|
|
usable: false,
|
|
error: '账号启动失败:private startup detail;将自动重试',
|
|
});
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks[1].start).toHaveBeenCalledOnce();
|
|
internals.onSessionClosed('10001');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('reports invalid account configuration and stops retrying after database readiness', async () => {
|
|
const originalCwd = process.cwd();
|
|
const root = mkdtempSync(path.join(tmpdir(), 'snowluma-invalid-startup-'));
|
|
const entries: string[] = [];
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry.message));
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const cancel = vi.fn();
|
|
const beginMigration = vi.fn();
|
|
const createTask = vi.fn(() => ({
|
|
start: (next: DatabaseMigrationCallbacks) => {
|
|
callbacks = next;
|
|
},
|
|
cancel,
|
|
beginMigration,
|
|
}));
|
|
const createInstance = vi.fn();
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: createTask,
|
|
createInstance,
|
|
});
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
process.chdir(root);
|
|
mkdirSync('config');
|
|
const config = makeDefaultOneBotConfig();
|
|
config.networks.wsServers[0].port = config.networks.httpServers[0].port;
|
|
config.networks.wsServers[0].host = config.networks.httpServers[0].host;
|
|
writeFileSync('config/onebot_10001.json', JSON.stringify(config));
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted('10001', fakeBridge() as never);
|
|
callbacks.onReady();
|
|
const state = manager.getConnectionStatuses()[0].databaseMigration!;
|
|
expect(state).toMatchObject({ phase: 'failed', usable: false });
|
|
expect(state.error).toContain('账号启动失败');
|
|
expect(state.error).toContain('conflicts with');
|
|
expect(
|
|
entries.some(
|
|
(message) =>
|
|
message.includes('session startup failed') &&
|
|
message.includes('OneBotConfigValidationError') &&
|
|
message.includes('conflicts with'),
|
|
),
|
|
).toBe(true);
|
|
expect(createInstance).not.toHaveBeenCalled();
|
|
expect(beginMigration).not.toHaveBeenCalled();
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
vi.advanceTimersByTime(60_000);
|
|
expect(createTask).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await manager.dispose();
|
|
unsubscribe();
|
|
vi.useRealTimers();
|
|
process.chdir(originalCwd);
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('retries a failed database task while the session remains present', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const callbacks: DatabaseMigrationCallbacks[] = [];
|
|
const tasks: DatabaseMigrationTask[] = [];
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => {
|
|
const task: DatabaseMigrationTask = {
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: vi.fn((next) => {
|
|
callbacks.push(next);
|
|
}),
|
|
};
|
|
tasks.push(task);
|
|
return task;
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
|
|
internals.onSessionStarted('10001', {} as never);
|
|
callbacks[0].onFailed(new Error('private detail'));
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toMatchObject({
|
|
phase: 'failed',
|
|
usable: false,
|
|
error: '数据库迁移失败:private detail;将自动重试',
|
|
});
|
|
const failed = manager.getConnectionStatuses()[0].databaseMigration;
|
|
callbacks[0].onProgress({ phase: 'complete', processed: 10, total: 10 }, 100);
|
|
callbacks[0].onFailed(new Error('duplicate failure'));
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toBe(failed);
|
|
expect(tasks[0].cancel).toHaveBeenCalledOnce();
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks[1].start).toHaveBeenCalledOnce();
|
|
const preparing = manager.getConnectionStatuses()[0].databaseMigration;
|
|
callbacks[0].onProgress({ phase: 'complete', processed: 10, total: 10 }, 100);
|
|
callbacks[0].onFailed(new Error('obsolete task failure'));
|
|
expect(manager.getConnectionStatuses()[0].databaseMigration).toBe(preparing);
|
|
expect(tasks[1].cancel).not.toHaveBeenCalled();
|
|
|
|
internals.onSessionClosed('10001');
|
|
callbacks[1].onFailed(new Error('late failure'));
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(tasks).toHaveLength(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('OneBotManager login history sync', () => {
|
|
async function withTempCwd(run: () => Promise<void>): Promise<void> {
|
|
const originalCwd = process.cwd();
|
|
const root = mkdtempSync(path.join(tmpdir(), 'snowluma-manager-history-'));
|
|
process.chdir(root);
|
|
|
|
try {
|
|
await run();
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function preparingManager(instance: OneBotInstance) {
|
|
let callbacks!: DatabaseMigrationCallbacks;
|
|
const manager = new OneBotManager({
|
|
createDatabaseMigrationTask: () => ({
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: (next) => {
|
|
callbacks = next;
|
|
},
|
|
}),
|
|
createInstance: () => instance,
|
|
});
|
|
return {
|
|
manager,
|
|
ready: () => callbacks.onReady(),
|
|
};
|
|
}
|
|
|
|
it('does not start history sync when roster warmup is incomplete', async () => {
|
|
await withTempCwd(async () => {
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({
|
|
applied: true,
|
|
statuses: [],
|
|
errors: [],
|
|
})),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const { manager, ready } = preparingManager(instance);
|
|
|
|
try {
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted('10001', fakeBridge() as never);
|
|
ready();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(instance.startLoginHistorySync).not.toHaveBeenCalled();
|
|
} finally {
|
|
await manager.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('starts history sync only after the instance is usable and warmup has both lists', async () => {
|
|
await withTempCwd(async () => {
|
|
let settleWarmup!: (result: { friendsLoaded: boolean; groupsLoaded: boolean }) => void;
|
|
const warmup = new Promise<{ friendsLoaded: boolean; groupsLoaded: boolean }>(
|
|
(resolve) => {
|
|
settleWarmup = resolve;
|
|
},
|
|
);
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({
|
|
applied: true,
|
|
statuses: [],
|
|
errors: [],
|
|
})),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const { manager, ready } = preparingManager(instance);
|
|
|
|
try {
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted('10001', fakeBridge(warmup) as never);
|
|
ready();
|
|
await Promise.resolve();
|
|
expect(instance.startLoginHistorySync).not.toHaveBeenCalled();
|
|
settleWarmup({ friendsLoaded: true, groupsLoaded: true });
|
|
await vi.waitFor(() => {
|
|
expect(instance.startLoginHistorySync).toHaveBeenCalledOnce();
|
|
});
|
|
} finally {
|
|
await manager.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('starts history sync when warmup finished before the instance became usable', async () => {
|
|
await withTempCwd(async () => {
|
|
const instance = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({
|
|
applied: true,
|
|
statuses: [],
|
|
errors: [],
|
|
})),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const { manager, ready } = preparingManager(instance);
|
|
|
|
try {
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted(
|
|
'10001',
|
|
fakeBridge(
|
|
Promise.resolve({
|
|
friendsLoaded: true,
|
|
groupsLoaded: true,
|
|
}),
|
|
) as never,
|
|
);
|
|
expect(instance.startLoginHistorySync).not.toHaveBeenCalled();
|
|
ready();
|
|
await vi.waitFor(() => {
|
|
expect(instance.startLoginHistorySync).toHaveBeenCalledOnce();
|
|
});
|
|
} finally {
|
|
await manager.dispose();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OneBotManager lifecycle failure accounting', () => {
|
|
it('quiesces active instances before waiting for pending lifecycle work', async () => {
|
|
const manager = new OneBotManager();
|
|
let finishLifecycle!: () => void;
|
|
const lifecycleGate = new Promise<void>((resolve) => {
|
|
finishLifecycle = resolve;
|
|
});
|
|
const quiesce = vi.fn();
|
|
const dispose = vi.fn(async () => ({ closed: true, errors: [] }));
|
|
const instance = fakeInstance('10001', dispose, [], quiesce);
|
|
const internals = manager as unknown as {
|
|
instances: Map<string, OneBotInstance>;
|
|
trackLifecycle(label: string, operation: Promise<unknown>): void;
|
|
};
|
|
internals.instances.set('10001', instance);
|
|
internals.trackLifecycle('deferred startup', lifecycleGate);
|
|
|
|
const disposing = manager.dispose();
|
|
expect(quiesce).toHaveBeenCalledOnce();
|
|
expect(dispose).not.toHaveBeenCalled();
|
|
|
|
finishLifecycle();
|
|
await disposing;
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('retains a tracked rejection until final dispose reports it', async () => {
|
|
const manager = new OneBotManager();
|
|
(
|
|
manager as unknown as {
|
|
trackLifecycle(label: string, operation: Promise<unknown>): void;
|
|
}
|
|
).trackLifecycle('probe shutdown', Promise.reject(new Error('release failed')));
|
|
|
|
await expect(manager.dispose()).rejects.toThrow(/failed to dispose OneBot manager cleanly/);
|
|
});
|
|
|
|
it('keeps a failed retire visible and blocks a conflicting same-UIN generation', async () => {
|
|
const manager = new OneBotManager();
|
|
const degraded: AdapterStatus = {
|
|
name: 'old-http',
|
|
kind: 'httpServer',
|
|
status: 'degraded',
|
|
detail: 'release failed',
|
|
lastError: 'release failed',
|
|
lastErrorAt: Date.now(),
|
|
};
|
|
const old = fakeInstance(
|
|
'10001',
|
|
async () => {
|
|
throw new Error('release failed');
|
|
},
|
|
[degraded],
|
|
);
|
|
(manager as unknown as { retiringInstances: Set<OneBotInstance> }).retiringInstances.add(
|
|
old,
|
|
);
|
|
const bridge = {} as never;
|
|
|
|
(
|
|
manager as unknown as { onSessionStarted(uin: string, bridge: never): void }
|
|
).onSessionStarted('10001', bridge);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(manager.getInstance('10001')).toBeNull();
|
|
expect(manager.getConnectionStatuses()).toEqual([
|
|
{
|
|
uin: '10001',
|
|
nickname: 'retiring-10001',
|
|
adapters: [degraded],
|
|
},
|
|
]);
|
|
expect(
|
|
(manager as unknown as { pendingStarts: Map<string, unknown> }).pendingStarts.has(
|
|
'10001',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('starts the replacement account when release never finishes', async () => {
|
|
const originalCwd = process.cwd();
|
|
const savedDeadline = process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS;
|
|
delete process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS;
|
|
const root = mkdtempSync(path.join(tmpdir(), 'snowluma-manager-release-'));
|
|
process.chdir(root);
|
|
const forceRelease = vi.fn();
|
|
const old = {
|
|
...fakeInstance('10001', () => new Promise(() => undefined)),
|
|
forceRelease,
|
|
} as unknown as OneBotInstance;
|
|
const replacement = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({ applied: true, statuses: [], errors: [] })),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
let created = false;
|
|
const manager = new OneBotManager({
|
|
sessionReleaseDeadlineMs: 30,
|
|
createDatabaseMigrationTask: () => ({
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: (next) => {
|
|
next.onReady();
|
|
},
|
|
}),
|
|
createInstance: () => {
|
|
created = true;
|
|
return replacement;
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
retiringInstances: Set<OneBotInstance>;
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
pendingStarts: Map<string, unknown>;
|
|
};
|
|
|
|
try {
|
|
internals.retiringInstances.add(old);
|
|
internals.onSessionStarted('10001', fakeBridge() as never);
|
|
await vi.waitFor(() => {
|
|
expect(created).toBe(true);
|
|
});
|
|
expect(forceRelease).toHaveBeenCalledOnce();
|
|
expect(manager.getInstance('10001')).toBe(replacement);
|
|
expect(internals.pendingStarts.has('10001')).toBe(false);
|
|
expect(internals.retiringInstances.has(old)).toBe(false);
|
|
} finally {
|
|
if (savedDeadline === undefined) {
|
|
delete process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS;
|
|
} else {
|
|
process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS = savedDeadline;
|
|
}
|
|
|
|
process.chdir(originalCwd);
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses SNOWLUMA_SESSION_RELEASE_DEADLINE_MS instead of a longer configured deadline', async () => {
|
|
const originalCwd = process.cwd();
|
|
const savedDeadline = process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS;
|
|
process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS = '40';
|
|
const root = mkdtempSync(path.join(tmpdir(), 'snowluma-manager-release-env-'));
|
|
process.chdir(root);
|
|
const forceRelease = vi.fn();
|
|
const old = {
|
|
...fakeInstance('10001', () => new Promise(() => undefined)),
|
|
forceRelease,
|
|
} as unknown as OneBotInstance;
|
|
let created = false;
|
|
const replacement = {
|
|
...fakeInstance('10001', async () => undefined),
|
|
waitUntilNetworkReady: vi.fn(async () => ({ applied: true, statuses: [], errors: [] })),
|
|
startLoginHistorySync: vi.fn(),
|
|
} as unknown as OneBotInstance;
|
|
const manager = new OneBotManager({
|
|
sessionReleaseDeadlineMs: 60_000,
|
|
createDatabaseMigrationTask: () => ({
|
|
beginMigration: vi.fn(),
|
|
cancel: vi.fn(),
|
|
start: (next) => {
|
|
next.onReady();
|
|
},
|
|
}),
|
|
createInstance: () => {
|
|
created = true;
|
|
return replacement;
|
|
},
|
|
});
|
|
const internals = manager as unknown as {
|
|
retiringInstances: Set<OneBotInstance>;
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
};
|
|
|
|
try {
|
|
internals.retiringInstances.add(old);
|
|
internals.onSessionStarted('10001', fakeBridge() as never);
|
|
await vi.waitFor(() => {
|
|
expect(created).toBe(true);
|
|
});
|
|
expect(forceRelease).toHaveBeenCalledOnce();
|
|
} finally {
|
|
if (savedDeadline === undefined) {
|
|
delete process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS;
|
|
} else {
|
|
process.env.SNOWLUMA_SESSION_RELEASE_DEADLINE_MS = savedDeadline;
|
|
}
|
|
|
|
process.chdir(originalCwd);
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows a later same-UIN start observation to retry a failed handoff', async () => {
|
|
const manager = new OneBotManager();
|
|
let attempts = 0;
|
|
let finishRetry!: () => void;
|
|
const retryGate = new Promise<void>((resolve) => {
|
|
finishRetry = resolve;
|
|
});
|
|
const old = fakeInstance('10001', async () => {
|
|
attempts += 1;
|
|
|
|
if (attempts === 1) {
|
|
throw new Error('first release failed');
|
|
}
|
|
|
|
await retryGate;
|
|
return { closed: true, errors: [] };
|
|
});
|
|
const internals = manager as unknown as {
|
|
retiringInstances: Set<OneBotInstance>;
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
onSessionClosed(uin: string): void;
|
|
pendingStarts: Map<string, unknown>;
|
|
};
|
|
internals.retiringInstances.add(old);
|
|
internals.onSessionStarted('10001', {} as never);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(internals.pendingStarts.has('10001')).toBe(false);
|
|
|
|
internals.onSessionStarted('10001', {} as never);
|
|
expect(internals.pendingStarts.has('10001')).toBe(true);
|
|
// The replacement session disappears while the retry is pending; cancel
|
|
// creation so the test never needs a concrete Bridge.
|
|
internals.onSessionClosed('10001');
|
|
finishRetry();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(attempts).toBe(2);
|
|
expect(manager.getInstance('10001')).toBeNull();
|
|
await expect(manager.dispose()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('binds a multi-generation handoff failure only to the generation that failed', async () => {
|
|
const manager = new OneBotManager();
|
|
const a = fakeInstance('10001', async () => ({ closed: true, errors: [] }));
|
|
let bAttempts = 0;
|
|
const b = fakeInstance('10001', async () => {
|
|
bAttempts += 1;
|
|
|
|
if (bAttempts === 1) {
|
|
throw new Error('B release failed');
|
|
}
|
|
|
|
return { closed: true, errors: [] };
|
|
});
|
|
const internals = manager as unknown as {
|
|
retiringInstances: Set<OneBotInstance>;
|
|
onSessionStarted(uin: string, bridge: never): void;
|
|
};
|
|
internals.retiringInstances.add(a);
|
|
internals.retiringInstances.add(b);
|
|
internals.onSessionStarted('10001', {} as never);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(internals.retiringInstances.has(a)).toBe(false);
|
|
expect(internals.retiringInstances.has(b)).toBe(true);
|
|
await expect(manager.dispose()).resolves.toBeUndefined();
|
|
expect(bAttempts).toBe(2);
|
|
});
|
|
|
|
it('does not let a successful same-UIN generation clear another generation failure', async () => {
|
|
const manager = new OneBotManager();
|
|
const a = fakeInstance('10001', async () => {
|
|
throw new Error('A still owns port');
|
|
});
|
|
const b = fakeInstance('10001', async () => undefined);
|
|
const internals = manager as unknown as {
|
|
retiringInstances: Set<OneBotInstance>;
|
|
trackLifecycle(
|
|
label: string,
|
|
operation: Promise<unknown>,
|
|
instances?: OneBotInstance[],
|
|
): void;
|
|
};
|
|
internals.retiringInstances.add(a);
|
|
internals.retiringInstances.add(b);
|
|
internals.trackLifecycle(
|
|
'network shutdown UIN=10001',
|
|
Promise.reject(new Error('A previous failure')),
|
|
[a],
|
|
);
|
|
|
|
await expect(manager.dispose()).rejects.toThrow(/failed to dispose OneBot manager cleanly/);
|
|
});
|
|
|
|
it('ignores session-closed after dispose so process-exit closed does not retire again', async () => {
|
|
const manager = new OneBotManager();
|
|
const dispose = vi.fn(async () => ({ closed: true, errors: [] }));
|
|
const instance = fakeInstance('10001', dispose);
|
|
const internals = manager as unknown as {
|
|
instances: Map<string, OneBotInstance>;
|
|
retiringInstances: Set<OneBotInstance>;
|
|
onSessionClosed(uin: string): void;
|
|
};
|
|
internals.instances.set('10001', instance);
|
|
|
|
await manager.dispose();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
|
|
const lateDispose = vi.fn(async () => ({ closed: true, errors: [] }));
|
|
const late = fakeInstance('10001', lateDispose);
|
|
internals.instances.set('10001', late);
|
|
internals.onSessionClosed('10001');
|
|
|
|
expect(lateDispose).not.toHaveBeenCalled();
|
|
expect(internals.retiringInstances.has(late)).toBe(false);
|
|
expect(manager.getInstance('10001')).toBe(late);
|
|
});
|
|
});
|