diff --git a/jest.config.js b/jest.config.js index 16302ce582..f2d6e43fcf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,8 +15,6 @@ module.exports = { }, coverageReporters: ['text', 'lcov'], coveragePathIgnorePatterns: [ - 'test-utils/src/ws-client', - 'test-utils/src/utils', 'tests/', 'dist/', ], diff --git a/packages/koishi-core/src/server.ts b/packages/koishi-core/src/server.ts index 72208af9b8..0de13d706d 100644 --- a/packages/koishi-core/src/server.ts +++ b/packages/koishi-core/src/server.ts @@ -97,7 +97,6 @@ export abstract class Server { Object.defineProperty(meta, '$ctxType', { value: ctxType }) const app = this.appMap[meta.selfId] - if (!app) return events // add context properties if (meta.postType === 'message') { @@ -179,8 +178,7 @@ export abstract class Server { apps[0].prepare(info.userId) } } catch (error) { - this.isListening = false - this._close() + this.close() throw error } } @@ -253,6 +251,7 @@ export class HttpServer extends Server { } async _listen () { + showServerLog('http server opening') const { port } = this.appList[0].options this.server.listen(port) try { @@ -260,7 +259,7 @@ export class HttpServer extends Server { } catch (error) { throw new Error('authorization failed') } - showServerLog('listen to port', port) + showServerLog('http server listen to', port) } _close () { @@ -275,16 +274,6 @@ export class WsClient extends Server { public socket: WebSocket private _listeners: Record void> = {} - constructor (app: App) { - super(app) - - this.socket = new WebSocket(app.options.server, { - headers: { - Authorization: `Bearer ${app.options.token}`, - }, - }) - } - send (data: any): Promise { data.echo = ++counter return new Promise((resolve, reject) => { @@ -297,6 +286,12 @@ export class WsClient extends Server { _listen (): Promise { return new Promise((resolve, reject) => { + showServerLog('websocket client opening') + const headers: Record = {} + const { token, server } = this.appList[0].options + if (token) headers.Authorization = `Bearer ${token}` + this.socket = new WebSocket(server, { headers }) + this.socket.once('error', reject) this.socket.once('open', () => { @@ -310,11 +305,12 @@ export class WsClient extends Server { let resolved = false this.socket.on('message', (data) => { data = data.toString() + showServerLog('receive', data) let parsed: any try { parsed = JSON.parse(data) } catch (error) { - return reject(data) + return reject(new Error(data)) } if (!resolved) { resolved = true @@ -338,32 +334,36 @@ export class WsClient extends Server { _close () { this.socket.close() - showServerLog('ws client closed') + showServerLog('websocket client closed') } } export type ServerType = 'http' | 'ws' // 'ws-reverse' -const serverTypes: Record, new (app: App) => Server]> = { - http: ['port', {}, HttpServer], - ws: ['server', {}, WsClient], -} +export const serverMap: Record> = { http: {}, ws: {} } export function createServer (app: App) { if (typeof app.options.type !== 'string') { throw new Error(errors.UNSUPPORTED_SERVER_TYPE) } app.options.type = app.options.type.toLowerCase() as any - if (!serverTypes[app.options.type]) { + let key: keyof any, Server: new (app: App) => Server + if (app.options.type === 'http') { + key = 'port' + Server = HttpServer + } else if (app.options.type === 'ws') { + key = 'server' + Server = WsClient + } else { throw new Error(errors.UNSUPPORTED_SERVER_TYPE) } - const [key, serverMap, Server] = serverTypes[app.options.type] - const value = app.options[key] as any + const servers = serverMap[app.options.type] + const value = app.options[key] if (!value) { throw new Error(format(errors.MISSING_CONFIGURATION, key)) } - if (value in serverMap) { - return serverMap[value].bind(app) + if (value in servers) { + return servers[value].bind(app) } - return serverMap[value] = new Server(app) + return servers[value] = new Server(app) } diff --git a/packages/koishi-core/tests/ws-client.spec.ts b/packages/koishi-core/tests/ws-client.spec.ts index 5eb1dbd9fc..de7303e4f9 100644 --- a/packages/koishi-core/tests/ws-client.spec.ts +++ b/packages/koishi-core/tests/ws-client.spec.ts @@ -1,41 +1,37 @@ -import { wsClient } from 'koishi-test-utils' -import { Meta } from 'koishi-core' +import { createWsServer, WsServer, BASE_SELF_ID } from 'koishi-test-utils' +import { App, Meta } from 'koishi-core' -const { createApp, createServer, postMeta, SERVER_PORT, emitter, nextTick } = wsClient +let server: WsServer +let app1: App, app2: App -const server2 = createServer(SERVER_PORT + 1, true) -const server = createServer() - -const app1 = createApp() -const app2 = createApp({ selfId: 515 }) - -jest.setTimeout(1000) - -beforeAll(() => { - return Promise.all([ - app1.start(), - app2.start(), - ]) +beforeAll(async () => { + server = await createWsServer() + app1 = server.createBoundApp() + app2 = server.createBoundApp({ selfId: BASE_SELF_ID + 1 }) }) -afterAll(() => { - server.close() - server2.close() - - return Promise.all([ - app1.stop(), - app2.stop(), - ]) -}) +afterAll(() => server.close()) describe('WebSocket Server', () => { - const mocks: jest.Mock[] = [] - for (let index = 0; index < 3; ++index) { - mocks.push(jest.fn()) - } + const app1MessageCallback = jest.fn() + const app2MessageCallback = jest.fn() - app1.receiver.on('message', mocks[0]) - app2.receiver.on('message', mocks[1]) + beforeAll(() => { + app1.receiver.on('message', app1MessageCallback) + app2.receiver.on('message', app2MessageCallback) + }) + + test('authorization', async () => { + server.token = 'token' + await expect(app1.start()).rejects.toHaveProperty('message', 'authorization failed') + app1.options.token = 'nekot' + await expect(app1.start()).rejects.toHaveProperty('message', 'authorization failed') + app1.options.token = 'token' + await expect(app1.start()).resolves.toBeUndefined() + server.token = null + await app1.stop() + await expect(app1.start()).resolves.toBeUndefined() + }) const meta: Meta = { postType: 'message', @@ -45,31 +41,25 @@ describe('WebSocket Server', () => { message: 'Hello', } - test('authorization failed', async () => { - const app = createApp({ server: `ws://localhost:${SERVER_PORT + 1}` }) - await expect(app.start()).rejects.toBe('authorization failed') - await app.stop() - }) - test('app binding', async () => { - await postMeta({ ...meta, selfId: 514 }) - expect(mocks[0]).toBeCalledTimes(1) - expect(mocks[1]).toBeCalledTimes(0) + await server.post({ ...meta, selfId: BASE_SELF_ID }) + expect(app1MessageCallback).toBeCalledTimes(1) + expect(app2MessageCallback).toBeCalledTimes(0) - await postMeta({ ...meta, selfId: 515 }) - expect(mocks[0]).toBeCalledTimes(1) - expect(mocks[1]).toBeCalledTimes(1) + await server.post({ ...meta, selfId: BASE_SELF_ID + 1 }) + expect(app1MessageCallback).toBeCalledTimes(1) + expect(app2MessageCallback).toBeCalledTimes(1) - await postMeta({ ...meta, selfId: 516 }) - expect(mocks[0]).toBeCalledTimes(1) - expect(mocks[1]).toBeCalledTimes(1) + await server.post({ ...meta, selfId: BASE_SELF_ID + 2 }) + expect(app1MessageCallback).toBeCalledTimes(1) + expect(app2MessageCallback).toBeCalledTimes(1) }) test('make polyfills', async () => { - await postMeta({ postType: 'message', groupId: 123, message: [{ type: 'text', data: { text: 'foo' } }] as any }) - await postMeta({ postType: 'message', groupId: 123, message: '', anonymous: 'foo' as any, ['anonymousFlag' as any]: 'bar' }) - await postMeta({ postType: 'request', userId: 123, requestType: 'friend', message: 'baz' }) - await postMeta({ postType: 'event' as any, userId: 123, ['event' as any]: 'frient_add' }) + await server.post({ postType: 'message', groupId: 123, message: [{ type: 'text', data: { text: 'foo' } }] as any }) + await server.post({ postType: 'message', groupId: 123, message: '', anonymous: 'foo' as any, ['anonymousFlag' as any]: 'bar' }) + await server.post({ postType: 'request', userId: 123, requestType: 'friend', message: 'baz' }) + await server.post({ postType: 'event' as any, userId: 123, ['event' as any]: 'frient_add' }) }) }) @@ -112,81 +102,63 @@ describe('Quick Operations', () => { test('message event', async () => { mock.mockClear() app1.receiver.once('message', meta => meta.$send('foo')) - emitter.once('send_group_msg_async', mock) - await postMeta(messageMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ group_id: 20000, message: 'foo' }) + await server.post(messageMeta) + await server.nextTick() + server.shouldHaveLastRequest('send_group_msg_async', { groupId: 20000, message: 'foo' }) mock.mockClear() app1.groups.receiver.once('message', meta => meta.$ban()) - emitter.once('set_group_ban_async', mock) - await postMeta(messageMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ group_id: 20000, user_id: 10000 }) + await server.post(messageMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_group_ban_async', { groupId: 20000, userId: 10000 }) mock.mockClear() app1.groups.receiver.once('message', meta => meta.$delete()) - emitter.once('delete_msg_async', mock) - await postMeta(messageMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ message_id: 99999 }) + await server.post(messageMeta) + await server.nextTick() + server.shouldHaveLastRequest('delete_msg_async', { messageId: 99999 }) mock.mockClear() app1.groups.receiver.once('message', meta => meta.$kick()) - emitter.once('set_group_kick_async', mock) - await postMeta(messageMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ group_id: 20000, user_id: 10000 }) + await server.post(messageMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_group_kick_async', { groupId: 20000, userId: 10000 }) mock.mockClear() app1.groups.receiver.once('message', meta => meta.$ban()) - emitter.once('set_group_anonymous_ban_async', mock) - await postMeta(anonymousMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ group_id: 20000, flag: 'flag' }) + await server.post(anonymousMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_group_anonymous_ban_async', { groupId: 20000, flag: 'flag' }) mock.mockClear() app1.groups.receiver.once('message', meta => meta.$kick()) - await postMeta(anonymousMeta) - await expect(nextTick()).rejects.toBeTruthy() + await server.post(anonymousMeta) + // should have no response, just make coverage happy }) test('request event', async () => { mock.mockClear() app1.receiver.once('request/friend', meta => meta.$approve('foo')) - emitter.once('set_friend_add_request_async', mock) - await postMeta(frientRequestMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ flag: 'foo', remark: 'foo', approve: true }) + await server.post(frientRequestMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_friend_add_request_async', { flag: 'foo', remark: 'foo', approve: true }) mock.mockClear() app1.receiver.once('request/friend', meta => meta.$reject()) - emitter.once('set_friend_add_request_async', mock) - await postMeta(frientRequestMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ flag: 'foo', approve: false }) + await server.post(frientRequestMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_friend_add_request_async', { flag: 'foo', approve: false }) mock.mockClear() app1.receiver.once('request/group/add', meta => meta.$approve()) - emitter.once('set_group_add_request_async', mock) - await postMeta(groupRequestMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ flag: 'bar', approve: true }) + await server.post(groupRequestMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_group_add_request_async', { flag: 'bar', approve: true }) mock.mockClear() app1.receiver.once('request/group/add', meta => meta.$reject('bar')) - emitter.once('set_group_add_request_async', mock) - await postMeta(groupRequestMeta) - await nextTick() - expect(mock).toBeCalledTimes(1) - expect(mock.mock.calls[0][0]).toMatchObject({ flag: 'bar', reason: 'bar', approve: false }) + await server.post(groupRequestMeta) + await server.nextTick() + server.shouldHaveLastRequest('set_group_add_request_async', { flag: 'bar', reason: 'bar', approve: false }) }) }) diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 0ec93979d0..f16436a8bd 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,9 +1,5 @@ -import * as wsClient from './ws-client' - export * from './database' export * from './memory' export * from './mocks' export * from './utils' -export * from './http-server' - -export { wsClient } +export * from './server' diff --git a/packages/test-utils/src/mocks.ts b/packages/test-utils/src/mocks.ts index 86d6dea512..a1b3709a20 100644 --- a/packages/test-utils/src/mocks.ts +++ b/packages/test-utils/src/mocks.ts @@ -1,4 +1,4 @@ -import { BASE_SELF_ID } from './utils' +import { BASE_SELF_ID, RequestData } from './utils' import { snakeCase, sleep } from 'koishi-utils' import { AppOptions, App, Sender, Server, ContextType, ResponsePayload, MessageMeta, Meta } from 'koishi-core' @@ -15,10 +15,8 @@ class MockedServer extends Server { } } -export type RequestInfo = readonly [string, Record] - class MockedSender extends Sender { - requests: RequestInfo[] = [] + requests: RequestData[] = [] constructor (app: App) { super(app) diff --git a/packages/test-utils/src/http-server.ts b/packages/test-utils/src/server.ts similarity index 53% rename from packages/test-utils/src/http-server.ts rename to packages/test-utils/src/server.ts index 3c833b1af4..6406653fd7 100644 --- a/packages/test-utils/src/http-server.ts +++ b/packages/test-utils/src/server.ts @@ -1,25 +1,49 @@ import { createHmac } from 'crypto' -import { Meta, App, AppOptions } from 'koishi-core' +import { EventEmitter } from 'events' +import { Meta, App, AppOptions, WsClient } from 'koishi-core' +import { RequestData, showTestLog, fromEntries, BASE_SELF_ID } from './utils' import { snakeCase, randomInt, camelCase } from 'koishi-utils' -import { Server, createServer } from 'http' -import { showTestLog, fromEntries, BASE_SELF_ID } from './utils' +import * as http from 'http' +import * as ws from 'ws' import getPort from 'get-port' import axios from 'axios' +export default class TestServer extends EventEmitter { + appList: App[] = [] + requests: RequestData[] = [] + server: { close: () => void } + responses: Record, number]> = {} + + async close () { + await Promise.all(this.appList.map(app => app.stop())) + this.server.close() + } + + shouldHaveLastRequest (method: string, params: Record) { + expect(this.requests[0]).toMatchObject([method, params]) + } + + setResponse (event: string, data: Record, retcode = 0) { + if (!data) { + this.responses[event] = null + } else { + this.responses[event] = [snakeCase(data), retcode] + } + } +} + export async function createHttpServer (token?: string) { const cqhttpPort = await getPort({ port: randomInt(16384, 49152) }) const koishiPort = await getPort({ port: randomInt(16384, 49152) }) return new HttpServer(cqhttpPort, koishiPort, token) } -export class HttpServer { - appList: App[] = [] - server: Server - requests: [string, Record][] = [] - responses: Record, number]> = {} +export class HttpServer extends TestServer { + server: http.Server constructor (public cqhttpPort: number, public koishiPort: number, public token?: string) { - this.server = createServer((req, res) => { + super() + this.server = http.createServer((req, res) => { let body = '' req.on('data', chunk => body += chunk) req.on('end', () => { @@ -52,15 +76,6 @@ export class HttpServer { }).listen(cqhttpPort) } - async close () { - await Promise.all(this.appList.map(app => app.stop())) - this.server.close() - } - - shouldHaveLastRequest (method: string, params: Record) { - expect(this.requests[0]).toMatchObject([method, params]) - } - post (meta: Meta, port = this.koishiPort, secret?: string) { const data = snakeCase(meta) const headers: any = {} @@ -71,14 +86,6 @@ export class HttpServer { return axios.post(`http://localhost:${port}`, data, { headers }) } - setResponse (event: string, data: Record, retcode = 0) { - if (!data) { - this.responses[event] = null - } else { - this.responses[event] = [snakeCase(data), retcode] - } - } - createBoundApp (options: AppOptions = {}) { const app = new App({ port: this.koishiPort, @@ -90,3 +97,60 @@ export class HttpServer { return app } } + +export async function createWsServer (token?: string) { + const cqhttpPort = await getPort({ port: randomInt(16384, 49152) }) + return new WsServer(cqhttpPort, token) +} + +export class WsServer extends TestServer { + server: ws.Server + + constructor (public cqhttpPort: number, public token?: string) { + super() + this.server = new ws.Server({ port: cqhttpPort }) + this.server.on('connection', (socket, req) => { + if (this.token) { + const signature = req.headers.authorization + if (!signature || signature !== `Bearer ${this.token}`) { + return socket.send('authorization failed', () => socket.close()) + } + } + + socket.on('message', (raw) => { + this.emit('message') + const parsed = JSON.parse(raw.toString()) + const { action, params, echo } = parsed + this.requests.unshift([action, camelCase(params)] as any) + const [data, retcode] = this.responses[action] || [{}, 0] + socket.send(JSON.stringify({ data, retcode, echo })) + }) + }) + } + + nextTick (): Promise { + return new Promise(resolve => this.on('message', resolve)) + } + + async post (meta: Meta) { + const data = snakeCase(meta) + showTestLog('websocket post:', data) + this.server.clients.forEach(socket => { + if (socket.readyState !== ws.OPEN) return + socket.send(JSON.stringify(data)) + }) + await Promise.all(this.appList.map(app => new Promise((resolve) => { + (app.server as WsClient).socket.once('message', resolve) + }))) + } + + createBoundApp (options: AppOptions = {}) { + const app = new App({ + server: `ws://localhost:${this.cqhttpPort}`, + selfId: BASE_SELF_ID, + ...options, + }) + this.appList.push(app) + return app + } +} diff --git a/packages/test-utils/src/utils.ts b/packages/test-utils/src/utils.ts index 60d45bde71..701d497463 100644 --- a/packages/test-utils/src/utils.ts +++ b/packages/test-utils/src/utils.ts @@ -5,6 +5,8 @@ import debug from 'debug' export const BASE_SELF_ID = 514 export const showTestLog = debug('koishi:test') +export type RequestData = readonly [string, Record] + /** * polyfill for node < 12.0 */ @@ -28,10 +30,6 @@ export function createArray (length: number, create: (index: number) => T) { return Array(length).fill(undefined).map((_, index) => create(index)) } -export function sum (nums: number[]) { - return nums.reduce((a, b) => a + b, 0) -} - export function createSender (userId: number, nickname: string, card = '') { return { userId, nickname, card, sex: 'unknown', age: 20 } as SenderInfo } diff --git a/packages/test-utils/src/ws-client.ts b/packages/test-utils/src/ws-client.ts deleted file mode 100644 index 6a96c7997e..0000000000 --- a/packages/test-utils/src/ws-client.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { AppOptions, App, Meta, appList, WsClient } from 'koishi-core' -import { snakeCase } from 'koishi-utils' -import { EventEmitter } from 'events' -import { Server } from 'ws' -import debug from 'debug' - -const showLog = debug('koishi:test') - -export const SERVER_PORT = 15700 -export const MAX_TIMEOUT = 100 -export const CLIENT_PORT = 17070 -export const SERVER_URL = `ws://localhost:${SERVER_PORT}` -export const emitter = new EventEmitter() - -export function createApp (options: AppOptions = {}) { - return new App({ - server: SERVER_URL, - selfId: 514, - ...options, - }) -} - -let _data = {} -let _retcode = 0 - -export function setResponse (data = {}, retcode = 0) { - _data = data - _retcode = retcode -} - -let server: Server -export function createServer (port = SERVER_PORT, fail = false) { - const _server = new Server({ port }) - if (!fail) server = _server - - _server.on('connection', (socket) => { - if (fail) return socket.send('authorization failed') - socket.on('message', (data) => { - const parsed = JSON.parse(data.toString()) - emitter.emit(parsed.action, parsed.params) - emitter.emit('*', parsed) - socket.send(JSON.stringify({ - echo: parsed.echo, - retcode: _retcode, - data: _data, - })) - }) - }) - - return _server -} - -export function nextTick () { - return new Promise((resolve, reject) => { - const listener = () => { - clearTimeout(timer) - resolve() - } - emitter.once('*', listener) - const timer = setTimeout(() => { - emitter.off('*', listener) - reject(new Error('timeout')) - }, MAX_TIMEOUT) - }) -} - -export function postMeta (meta: Meta) { - const data = JSON.stringify(snakeCase(meta)) - for (const socket of server.clients) { - socket.send(data) - } - return new Promise((resolve) => { - const listener = () => { - resolve() - for (const app of appList) { - (app.server as WsClient).socket.off('message', listener) - } - } - for (const app of appList) { - (app.server as WsClient).socket.on('message', listener) - } - }) -} diff --git a/test.js b/test.js new file mode 100644 index 0000000000..d53003a8ba --- /dev/null +++ b/test.js @@ -0,0 +1,20 @@ +const { App } = require('koishi') + +const app = new App({ + port: 7070, + server: 'http://localhost:5700', + secret: 'kouchya', + token: 'shigma', +}) + +app.middleware((meta, next) => { +meta.$send('工具人') +return next() +}) +app.receiver.on('message', (meta) => { +meta.$send('工具人') +}) + +app.start() + +console.log('app start...')