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,236 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import http, { type IncomingMessage } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { HttpServerAdapter } from '../src/network/http-server-adapter';
|
||||
import type { NetworkAdapterContext } from '../src/network/adapter';
|
||||
import { buildDispatchPayload } from '../src/event-filter';
|
||||
import type { HttpServerNetwork, JsonObject } from '../src/types';
|
||||
|
||||
const adapters: HttpServerAdapter[] = [];
|
||||
const responses: IncomingMessage[] = [];
|
||||
|
||||
async function start(overrides: Partial<HttpServerNetwork> = {}, uin = '10001') {
|
||||
const config: HttpServerNetwork = {
|
||||
name: 'sse',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
path: '/api',
|
||||
enableSse: true,
|
||||
accessToken: 'test-secret',
|
||||
...overrides,
|
||||
};
|
||||
const api = {
|
||||
isAcceptingActions: true,
|
||||
isStreamAction: () => false,
|
||||
handle: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { self_id: uin } })),
|
||||
};
|
||||
const adapter = new HttpServerAdapter(config.name, config, {
|
||||
uin,
|
||||
api,
|
||||
buildLifecycleEvent: (subType: string) => ({
|
||||
self_id: uin,
|
||||
post_type: 'meta_event',
|
||||
meta_event_type: 'lifecycle',
|
||||
sub_type: subType,
|
||||
}),
|
||||
buildHeartbeatEvent: () => ({
|
||||
self_id: uin,
|
||||
post_type: 'meta_event',
|
||||
meta_event_type: 'heartbeat',
|
||||
}),
|
||||
} as unknown as NetworkAdapterContext);
|
||||
adapters.push(adapter);
|
||||
await adapter.open();
|
||||
const port = ((adapter as unknown as { server: http.Server }).server.address() as AddressInfo)
|
||||
.port;
|
||||
const request = (
|
||||
path = '/api/_events?access_token=test-secret',
|
||||
headers: Record<string, string> = {},
|
||||
method = 'GET',
|
||||
): Promise<IncomingMessage> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path, headers, method },
|
||||
(res) => {
|
||||
responses.push(res);
|
||||
// A stream closed intentionally by the test/server can be aborted.
|
||||
res.on('error', () => undefined);
|
||||
resolve(res);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const emit = (id: number, postType = 'message') => {
|
||||
const event: JsonObject = {
|
||||
self_id: uin,
|
||||
post_type: postType,
|
||||
message_id: id,
|
||||
message: [{ type: 'text', data: { text: `line\n${id}` } }],
|
||||
raw_message: `line\n${id}`,
|
||||
};
|
||||
adapter.onEvent(event, buildDispatchPayload(event));
|
||||
};
|
||||
|
||||
return { adapter, config, request, emit, api };
|
||||
}
|
||||
|
||||
function collect(response: IncomingMessage) {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => {
|
||||
text += chunk;
|
||||
});
|
||||
const frames = () =>
|
||||
text
|
||||
.split('\n\n')
|
||||
.filter((frame) => frame.includes('data: '))
|
||||
.map((frame) => {
|
||||
const id = /^id: (.+)$/m.exec(frame)?.[1];
|
||||
const data = JSON.parse(/^data: (.+)$/m.exec(frame)![1]) as JsonObject;
|
||||
return { id, data };
|
||||
});
|
||||
return { frames, text: () => text };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
responses.splice(0).forEach((res) => res.destroy());
|
||||
await Promise.all(adapters.splice(0).map((adapter) => adapter.close()));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('HTTP OneBot SSE', () => {
|
||||
it('shares HTTP actions and prefix, authenticates all supported token forms, and frames events', async () => {
|
||||
const { adapter, request, emit, api } = await start();
|
||||
expect((await request('/wrong/_events?access_token=test-secret')).statusCode).toBe(404);
|
||||
expect((await request('/api/_events')).statusCode).toBe(401);
|
||||
expect((await request('/api/_events?access_token=wrong')).statusCode).toBe(401);
|
||||
expect((await request(undefined, {}, 'POST')).statusCode).toBe(405);
|
||||
const streams = [];
|
||||
|
||||
for (const [path, headers] of [
|
||||
['/api/_events?access_token=test-secret', {}],
|
||||
['/api/_events', { authorization: 'Bearer test-secret' }],
|
||||
['/api/_events', { authorization: 'test-secret' }],
|
||||
] as Array<[string, Record<string, string>]>) {
|
||||
const res = await request(path, headers);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
expect(res.headers['cache-control']).toBe('no-cache, no-transform');
|
||||
expect(res.headers['x-accel-buffering']).toBe('no');
|
||||
streams.push(collect(res));
|
||||
}
|
||||
|
||||
emit(1);
|
||||
emit(2, 'message_sent');
|
||||
emit(3);
|
||||
await vi.waitFor(() =>
|
||||
expect(streams.every((stream) => stream.frames().length === 5)).toBe(true),
|
||||
);
|
||||
expect(
|
||||
streams[0]
|
||||
.frames()
|
||||
.slice(3)
|
||||
.map((frame) => frame.data.message_id),
|
||||
).toEqual([1, 3]);
|
||||
expect(streams[0].frames()[3].data.message).toEqual([
|
||||
{ type: 'text', data: { text: 'line\n1' } },
|
||||
]);
|
||||
expect(streams[0].text()).toContain('retry: 3000\n\n');
|
||||
const action = await request('/api/get_status', { authorization: 'test-secret' }, 'POST');
|
||||
expect(action.statusCode).toBe(200);
|
||||
expect(api.handle).toHaveBeenCalledWith('get_status', {});
|
||||
expect(adapter.describeStatus().detail).toContain('SSE 3 个客户端');
|
||||
});
|
||||
|
||||
it('replays exactly the missing events and maintains report format and self-message policy', async () => {
|
||||
const { request, emit } = await start({ messageFormat: 'string', reportSelfMessage: true });
|
||||
const initial = await request();
|
||||
const first = collect(initial);
|
||||
emit(1);
|
||||
await vi.waitFor(() => expect(first.frames()).toHaveLength(4));
|
||||
const cursor = first.frames()[3].id!;
|
||||
initial.destroy();
|
||||
emit(2, 'message_sent');
|
||||
emit(3);
|
||||
const resumed = collect(await request(undefined, { 'Last-Event-ID': cursor }));
|
||||
await vi.waitFor(() => expect(resumed.frames()).toHaveLength(5));
|
||||
expect(
|
||||
resumed
|
||||
.frames()
|
||||
.slice(0, 2)
|
||||
.map((frame) => frame.data.message_id),
|
||||
).toEqual([2, 3]);
|
||||
expect(resumed.frames()[0].data.message).toBe('line\n2');
|
||||
expect(resumed.frames()[0].data.post_type).toBe('message_sent');
|
||||
});
|
||||
|
||||
it('rejects expired, malformed, future and other-account cursors without silently losing events', async () => {
|
||||
const first = await start();
|
||||
const second = await start({}, '10002');
|
||||
const stream = collect(await first.request());
|
||||
await vi.waitFor(() => expect(stream.frames()).toHaveLength(3));
|
||||
const cursor = stream.frames()[0].id!;
|
||||
const header = { 'Last-Event-ID': cursor };
|
||||
expect((await second.request(undefined, header)).statusCode).toBe(409);
|
||||
expect((await first.request(undefined, { 'Last-Event-ID': 'broken' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(
|
||||
(await first.request(undefined, { 'Last-Event-ID': cursor.replace(/:0$/, ':99') }))
|
||||
.statusCode,
|
||||
).toBe(409);
|
||||
|
||||
for (let i = 0; i < 257; i += 1) {
|
||||
first.emit(i);
|
||||
}
|
||||
|
||||
expect((await first.request(undefined, header)).statusCode).toBe(409);
|
||||
// A new subscription remains possible after explicit resynchronization.
|
||||
expect((await first.request()).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('revokes live streams on token/report policy changes and closes without waiting on SSE', async () => {
|
||||
const { adapter, config, request } = await start();
|
||||
const res = await request();
|
||||
const closed = new Promise<void>((resolve) => res.once('close', resolve));
|
||||
await adapter.reload({ ...config, accessToken: 'rotated' });
|
||||
await closed;
|
||||
expect((await request()).statusCode).toBe(401);
|
||||
const next = await request('/api/_events?access_token=rotated');
|
||||
const changed = new Promise<void>((resolve) => next.once('close', resolve));
|
||||
await adapter.reload({ ...config, accessToken: 'rotated', reportSelfMessage: true });
|
||||
await changed;
|
||||
const last = await request('/api/_events?access_token=rotated');
|
||||
const ended = new Promise<void>((resolve) => last.once('close', resolve));
|
||||
await adapter.close();
|
||||
await ended;
|
||||
expect(adapter.describeStatus().status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('is opt-in and can be enabled/disabled live without replacing the HTTP listener', async () => {
|
||||
const { adapter, config, request } = await start({ enableSse: false });
|
||||
expect((await request()).statusCode).toBe(404);
|
||||
await adapter.reload({ ...config, enableSse: true });
|
||||
const stream = await request();
|
||||
expect(stream.statusCode).toBe(200);
|
||||
const closed = new Promise<void>((resolve) => stream.once('close', resolve));
|
||||
await adapter.reload(config);
|
||||
await closed;
|
||||
expect((await request()).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('sends comment heartbeats and releases their timer when the client leaves', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
const { adapter, request } = await start();
|
||||
const res = await request();
|
||||
const stream = collect(res);
|
||||
await vi.waitFor(() => expect(stream.frames()).toHaveLength(3));
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await vi.waitFor(() => expect(stream.text()).toContain(': ping\n\n'));
|
||||
res.destroy();
|
||||
await vi.waitFor(() => expect(adapter.describeStatus().detail).toContain('SSE 0 个客户端'));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user