SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
IOneBotNetworkAdapter,
|
||||
OneBotNetworkManager,
|
||||
type AdapterStatus,
|
||||
type DesiredNetworkAdapter,
|
||||
type NetworkAdapterKind,
|
||||
type NetworkAdapterContext,
|
||||
} from '../src/network';
|
||||
import type { DispatchPayload } from '../src/event-filter';
|
||||
import type { JsonObject, NetworkBase, OneBotConfig } from '../src/types';
|
||||
|
||||
const CTX: NetworkAdapterContext = {
|
||||
uin: '10001',
|
||||
api: {} as never,
|
||||
buildLifecycleEvent: () => ({}),
|
||||
buildHeartbeatEvent: () => ({}),
|
||||
};
|
||||
|
||||
interface Gate {
|
||||
promise: Promise<void>;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
function gate(): Gate {
|
||||
let release!: () => void;
|
||||
return {
|
||||
promise: new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
release: () => release(),
|
||||
};
|
||||
}
|
||||
|
||||
class ProbeAdapter extends IOneBotNetworkAdapter<NetworkBase> {
|
||||
private closeAttempts = 0;
|
||||
failNextOpen = false;
|
||||
constructor(
|
||||
name: string,
|
||||
config: NetworkBase,
|
||||
readonly kind: NetworkAdapterKind,
|
||||
private readonly trace: string[],
|
||||
private readonly slowOpen: Gate,
|
||||
) {
|
||||
super(name, config, CTX);
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
const marker = this.config.accessToken ?? '';
|
||||
this.trace.push(`open:${this.kind}:${this.name}:${marker}`);
|
||||
|
||||
if (this.failNextOpen) {
|
||||
this.failNextOpen = false;
|
||||
throw new Error(`restore failed for ${this.name}`);
|
||||
}
|
||||
|
||||
if (marker === 'slow') {
|
||||
await this.slowOpen.promise;
|
||||
}
|
||||
|
||||
if (marker === 'fail' || marker === 'fail-open-close') {
|
||||
throw new Error(`bind failed for ${this.name}`);
|
||||
}
|
||||
|
||||
if (this.config.enabled !== false) {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const marker = this.config.accessToken ?? '';
|
||||
this.trace.push(`close:${this.kind}:${this.name}:${marker}`);
|
||||
this.closeAttempts += 1;
|
||||
|
||||
if (
|
||||
marker === 'fail-close' ||
|
||||
marker === 'fail-open-close' ||
|
||||
(marker === 'fail-close-once' && this.closeAttempts === 1)
|
||||
) {
|
||||
throw new Error(`close failed for ${this.name}`);
|
||||
}
|
||||
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
protected bindingSignature(config: NetworkBase): string {
|
||||
return config.accessToken ?? '';
|
||||
}
|
||||
|
||||
describeStatus(): AdapterStatus {
|
||||
return {
|
||||
name: this.name,
|
||||
kind: this.kind,
|
||||
status: this.isActive ? 'ok' : 'disabled',
|
||||
detail: this.isActive ? 'active' : 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
onEvent(_event: JsonObject, _payload: DispatchPayload): void {
|
||||
/* unused */
|
||||
}
|
||||
}
|
||||
|
||||
function emptyConfig(): OneBotConfig {
|
||||
return {
|
||||
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
|
||||
statusCommand: { enabled: true, swallow: false, cooldownSeconds: 5, trigger: '#sl' },
|
||||
historySync: { enabled: false },
|
||||
notifications: { channelIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function httpClient(name: string, marker: string, enabled = true) {
|
||||
return {
|
||||
name,
|
||||
enabled,
|
||||
url: `http://localhost/${name}`,
|
||||
accessToken: marker,
|
||||
messageFormat: 'array' as const,
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
}
|
||||
|
||||
function wsClient(name: string, marker: string) {
|
||||
return {
|
||||
name,
|
||||
url: `ws://localhost/${name}`,
|
||||
accessToken: marker,
|
||||
role: 'Universal' as const,
|
||||
reconnectIntervalMs: 1000,
|
||||
messageFormat: 'array' as const,
|
||||
reportSelfMessage: false,
|
||||
};
|
||||
}
|
||||
|
||||
function manager(trace: string[], slowOpen: Gate): OneBotNetworkManager {
|
||||
return new OneBotNetworkManager(
|
||||
(desired: DesiredNetworkAdapter) =>
|
||||
new ProbeAdapter(desired.name, desired.config, desired.kind, trace, slowOpen),
|
||||
);
|
||||
}
|
||||
|
||||
describe('OneBotNetworkManager desired-plan reconciliation', () => {
|
||||
it('rejects a duplicate name across kinds before creating any adapter', () => {
|
||||
const trace: string[] = [];
|
||||
const config = emptyConfig();
|
||||
config.networks.httpClients.push(httpClient('shared', 'a'));
|
||||
config.networks.wsClients.push(wsClient('shared', 'b'));
|
||||
const mgr = manager(trace, gate());
|
||||
|
||||
expect(() => mgr.reconcile(config)).toThrow(/duplicated in httpClients and wsClients/);
|
||||
expect(trace).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes and recreates when a name changes kind', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
trace.length = 0;
|
||||
|
||||
const next = emptyConfig();
|
||||
next.networks.wsClients.push(wsClient('edge', 'new'));
|
||||
const result = await mgr.reconcile(next);
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(trace).toEqual(['close:httpClient:edge:old', 'open:wsClient:edge:new']);
|
||||
expect(result.statuses[0]).toMatchObject({ name: 'edge', kind: 'wsClient', status: 'ok' });
|
||||
});
|
||||
|
||||
it('serializes consecutive reconciles in invocation order', async () => {
|
||||
const trace: string[] = [];
|
||||
const slow = gate();
|
||||
const mgr = manager(trace, slow);
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
trace.length = 0;
|
||||
|
||||
const second = emptyConfig();
|
||||
second.networks.httpClients.push(httpClient('edge', 'slow'));
|
||||
const third = emptyConfig();
|
||||
third.networks.httpClients.push(httpClient('edge', 'final'));
|
||||
const applyingSecond = mgr.reconcile(second);
|
||||
const applyingThird = mgr.reconcile(third);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(trace).toEqual(['close:httpClient:edge:old', 'open:httpClient:edge:slow']);
|
||||
slow.release();
|
||||
await Promise.all([applyingSecond, applyingThird]);
|
||||
expect(trace).toEqual([
|
||||
'close:httpClient:edge:old',
|
||||
'open:httpClient:edge:slow',
|
||||
'close:httpClient:edge:slow',
|
||||
'open:httpClient:edge:final',
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores the previous live config and exposes degraded status when reload fails', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
trace.length = 0;
|
||||
|
||||
const next = emptyConfig();
|
||||
next.networks.httpClients.push(httpClient('edge', 'fail'));
|
||||
const result = await mgr.reconcile(next);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.errors).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'edge',
|
||||
phase: 'reload',
|
||||
restored: true,
|
||||
message: 'bind failed for edge',
|
||||
}),
|
||||
]);
|
||||
expect(trace).toEqual([
|
||||
'close:httpClient:edge:old',
|
||||
'open:httpClient:edge:fail',
|
||||
'open:httpClient:edge:old',
|
||||
]);
|
||||
expect(mgr.get('edge')?.currentConfig.accessToken).toBe('old');
|
||||
expect(result.statuses[0]).toMatchObject({
|
||||
name: 'edge',
|
||||
status: 'degraded',
|
||||
lastError: 'bind failed for edge',
|
||||
});
|
||||
});
|
||||
|
||||
it('queues shutdown behind an active reload and rejects later reloads', async () => {
|
||||
const trace: string[] = [];
|
||||
const slow = gate();
|
||||
const mgr = manager(trace, slow);
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
trace.length = 0;
|
||||
|
||||
const next = emptyConfig();
|
||||
next.networks.httpClients.push(httpClient('edge', 'slow'));
|
||||
const applying = mgr.reconcile(next);
|
||||
const closing = mgr.shutdown();
|
||||
await expect(mgr.reconcile(first)).rejects.toThrow(/shutting down/);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(trace).toEqual(['close:httpClient:edge:old', 'open:httpClient:edge:slow']);
|
||||
|
||||
slow.release();
|
||||
await applying;
|
||||
await expect(closing).resolves.toMatchObject({ closed: true, errors: [] });
|
||||
expect(trace.at(-1)).toBe('close:httpClient:edge:slow');
|
||||
});
|
||||
|
||||
it('shuts adapters down sequentially in reverse creation order', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const config = emptyConfig();
|
||||
config.networks.httpClients.push(httpClient('a', '1'), httpClient('b', '2'));
|
||||
await mgr.reconcile(config);
|
||||
trace.length = 0;
|
||||
|
||||
await mgr.shutdown();
|
||||
expect(trace).toEqual(['close:httpClient:b:2', 'close:httpClient:a:1']);
|
||||
});
|
||||
|
||||
it('retains failed shutdown adapters and allows a later close retry', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const config = emptyConfig();
|
||||
config.networks.httpClients.push(httpClient('edge', 'fail-close-once'));
|
||||
await mgr.reconcile(config);
|
||||
trace.length = 0;
|
||||
|
||||
const first = await mgr.shutdown();
|
||||
expect(first.closed).toBe(false);
|
||||
expect(mgr.get('edge')).not.toBeNull();
|
||||
expect(mgr.describeStatuses()[0]).toMatchObject({
|
||||
status: 'degraded',
|
||||
lastError: 'close failed for edge',
|
||||
});
|
||||
|
||||
const second = await mgr.shutdown();
|
||||
expect(second).toEqual({ closed: true, errors: [] });
|
||||
expect(mgr.get('edge')).toBeNull();
|
||||
expect(trace).toEqual([
|
||||
'close:httpClient:edge:fail-close-once',
|
||||
'close:httpClient:edge:fail-close-once',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a replacement managed when its cleanup close fails', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
trace.length = 0;
|
||||
|
||||
const next = emptyConfig();
|
||||
next.networks.wsClients.push(wsClient('edge', 'fail-open-close'));
|
||||
const result = await mgr.reconcile(next);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(trace).toEqual([
|
||||
'close:httpClient:edge:old',
|
||||
'open:wsClient:edge:fail-open-close',
|
||||
'close:wsClient:edge:fail-open-close',
|
||||
]);
|
||||
expect(mgr.describeStatuses()[0]).toMatchObject({
|
||||
name: 'edge',
|
||||
kind: 'wsClient',
|
||||
status: 'degraded',
|
||||
lastError: 'bind failed for edge',
|
||||
});
|
||||
expect(result.errors.map((error) => error.phase)).toEqual(['close', 'replace']);
|
||||
});
|
||||
|
||||
it('marks the old adapter degraded when factory creation and restoration both fail', async () => {
|
||||
const trace: string[] = [];
|
||||
const slow = gate();
|
||||
const mgr = new OneBotNetworkManager((desired) => {
|
||||
if (desired.kind === 'wsClient') {
|
||||
throw new Error('factory failed');
|
||||
}
|
||||
|
||||
return new ProbeAdapter(desired.name, desired.config, desired.kind, trace, slow);
|
||||
});
|
||||
const first = emptyConfig();
|
||||
first.networks.httpClients.push(httpClient('edge', 'old'));
|
||||
await mgr.reconcile(first);
|
||||
(mgr.get('edge') as ProbeAdapter).failNextOpen = true;
|
||||
|
||||
const next = emptyConfig();
|
||||
next.networks.wsClients.push(wsClient('edge', 'new'));
|
||||
const result = await mgr.reconcile(next);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.errors.map((error) => error.phase)).toEqual(['restore', 'replace']);
|
||||
expect(mgr.describeStatuses()[0]).toMatchObject({
|
||||
name: 'edge',
|
||||
kind: 'httpClient',
|
||||
status: 'degraded',
|
||||
lastError: 'factory failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('isolates one failed adapter while applying the remaining plan', async () => {
|
||||
const trace: string[] = [];
|
||||
const mgr = manager(trace, gate());
|
||||
const config = emptyConfig();
|
||||
config.networks.httpClients.push(httpClient('bad', 'fail'), httpClient('good', 'ok'));
|
||||
|
||||
const result = await mgr.reconcile(config);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.statuses).toEqual([
|
||||
expect.objectContaining({ name: 'bad', status: 'degraded' }),
|
||||
expect.objectContaining({ name: 'good', status: 'ok' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user