diff --git a/packages/consul/src/manager.ts b/packages/consul/src/manager.ts index 6f61b7928939..6eb3bcd4bcb3 100644 --- a/packages/consul/src/manager.ts +++ b/packages/consul/src/manager.ts @@ -45,6 +45,13 @@ export class ConsulServiceFactory extends ServiceFactory { config: ConsulOptions, clientName: string ): Promise> { + const { customClientClass, ...otherConfig } = config as any; + if (customClientClass) { + const client = new customClientClass(otherConfig); + this.bindTraceContext(client as any, clientName); + return client; + } + this.logger.info( '[midway:consul] init %s at %s:%s', clientName, diff --git a/packages/consul/test/customClient.test.ts b/packages/consul/test/customClient.test.ts new file mode 100644 index 000000000000..cf36d386fe4e --- /dev/null +++ b/packages/consul/test/customClient.test.ts @@ -0,0 +1,103 @@ +import { close, createLightApp } from '@midwayjs/mock'; +import * as consul from '../src'; + +describe('/test/customClient.test.ts', () => { + class CustomConsul { + constructor(public config: any) {} + + request(options: any) { + return Promise.resolve(options.method); + } + + destroy() {} + } + + it('should create a custom client from merged default config', async () => { + const app = await createLightApp({ + imports: [consul], + globalConfig: { + consul: { + default: { + customClientClass: CustomConsul, + }, + client: { + host: 'default.local', + port: 8500, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(consul.ConsulServiceFactory); + + const client = factory.get(); + expect(client).toBeInstanceOf(CustomConsul); + expect(client.config).toEqual({ + host: 'default.local', + port: 8500, + }); + + await close(app); + }); + + it('should create custom clients and keep factory/service injection', async () => { + const app = await createLightApp({ + imports: [consul], + globalConfig: { + consul: { + clients: { + default: { + customClientClass: CustomConsul, + host: 'default.local', + port: 8500, + }, + backup: { + customClientClass: CustomConsul, + host: 'backup.local', + port: 8500, + }, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(consul.ConsulServiceFactory); + const service = await app + .getApplicationContext() + .getAsync(consul.ConsulService); + const defaultClient = factory.get(); + const backupClient = factory.get('backup'); + + expect(defaultClient).toBeInstanceOf(CustomConsul); + expect(backupClient).toBeInstanceOf(CustomConsul); + expect(defaultClient.config.customClientClass).toBeUndefined(); + expect(backupClient.config.host).toBe('backup.local'); + expect((service as any).instance).toBe(defaultClient); + + await close(app); + }); + + it('should bind trace context to custom client', async () => { + const factory = new consul.ConsulServiceFactory(); + const runWithExitSpan = jest.fn(async (_name, _options, callback) => { + return callback(); + }); + (factory as any).traceService = { runWithExitSpan }; + + const client: any = await factory.createClient( + { + customClientClass: CustomConsul, + host: 'localhost', + port: 8500, + } as any, + 'default' + ); + + expect(await client.request({ method: 'GET' })).toBe('GET'); + expect(runWithExitSpan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 98548b22dde1..c345ca96eedb 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -489,11 +489,15 @@ export type TraceMetaResolver = | ((args: TraceMetaResolverArgs) => TraceMetaRecord); }; +export type BaseServiceFactoryConfigOption = PowerPartial & { + customClientClass?: any; +}; + export type ServiceFactoryConfigOption = { - default?: PowerPartial; - client?: PowerPartial; + default?: BaseServiceFactoryConfigOption; + client?: BaseServiceFactoryConfigOption; clients?: { - [key: string]: PowerPartial; + [key: string]: BaseServiceFactoryConfigOption; }; defaultClientName?: string; clientPriority?: { diff --git a/packages/core/test/common/serviceFactory.test.ts b/packages/core/test/common/serviceFactory.test.ts index faec11e73010..523ac4dabc13 100644 --- a/packages/core/test/common/serviceFactory.test.ts +++ b/packages/core/test/common/serviceFactory.test.ts @@ -1,4 +1,10 @@ -import { ServiceFactory, DEFAULT_PRIORITY, MidwayPriorityManager, sleep } from '../../src'; +import { + ServiceFactory, + ServiceFactoryConfigOption, + DEFAULT_PRIORITY, + MidwayPriorityManager, + sleep, +} from '../../src'; describe('test/common/serviceFactory.test.ts', () => { @@ -74,6 +80,30 @@ describe('test/common/serviceFactory.test.ts', () => { expect(instance.get('default')).toBeDefined(); }); + it('should support custom client class in service factory config types', () => { + class CustomClient {} + + const options: ServiceFactoryConfigOption<{ host: string }> = { + default: { + customClientClass: CustomClient, + }, + client: { + customClientClass: CustomClient, + host: '127.0.0.1', + }, + clients: { + backup: { + customClientClass: CustomClient, + host: '127.0.0.2', + }, + }, + }; + + expect(options.default.customClientClass).toBe(CustomClient); + expect(options.client.customClientClass).toBe(CustomClient); + expect(options.clients.backup.customClientClass).toBe(CustomClient); + }); + it('should test default name', async () => { const instance = new TestServiceFactory(); await instance.initClients({ diff --git a/packages/cos/src/manager.ts b/packages/cos/src/manager.ts index 1f7f84a6a67e..8dd06a99aac4 100644 --- a/packages/cos/src/manager.ts +++ b/packages/cos/src/manager.ts @@ -43,6 +43,13 @@ export class COSServiceFactory extends ServiceFactory { protected traceInjector; async createClient(config: COS.COSOptions): Promise { + const { customClientClass, ...otherConfig } = config as any; + if (customClientClass) { + const client = new customClientClass(otherConfig); + this.bindTraceContext(client as any); + return client; + } + assert.ok( config.SecretKey && config.SecretId, '[@midwayjs/cos] secretId secretKey is required on config' diff --git a/packages/cos/test/customClient.test.ts b/packages/cos/test/customClient.test.ts new file mode 100644 index 000000000000..07198d02de9b --- /dev/null +++ b/packages/cos/test/customClient.test.ts @@ -0,0 +1,89 @@ +import { close, createLightApp } from '@midwayjs/mock'; +import * as cos from '../src'; + +describe('/test/customClient.test.ts', () => { + class CustomCOS { + constructor(public config: any) {} + + request(options: any) { + return Promise.resolve(options.Action); + } + } + + it('should create a custom client from client config', async () => { + const app = await createLightApp('', { + imports: [cos], + globalConfig: { + cos: { + client: { + customClientClass: CustomCOS, + SecretId: 'default-id', + SecretKey: 'secret', + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(cos.COSServiceFactory); + + expect(factory.get()).toBeInstanceOf(CustomCOS); + + await close(app); + }); + + it('should create custom clients and keep factory/service injection', async () => { + const app = await createLightApp('', { + imports: [cos], + globalConfig: { + cos: { + clients: { + default: { + customClientClass: CustomCOS, + SecretId: 'default-id', + SecretKey: 'secret', + }, + backup: { + customClientClass: CustomCOS, + SecretId: 'backup-id', + SecretKey: 'secret', + }, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(cos.COSServiceFactory); + const service = await app.getApplicationContext().getAsync(cos.COSService); + const defaultClient = factory.get(); + const backupClient = factory.get('backup'); + + expect(defaultClient).toBeInstanceOf(CustomCOS); + expect(backupClient).toBeInstanceOf(CustomCOS); + expect(defaultClient.config.customClientClass).toBeUndefined(); + expect(backupClient.config.SecretId).toBe('backup-id'); + expect((service as any).instance).toBe(defaultClient); + + await close(app); + }); + + it('should bind trace context to custom client', async () => { + const factory = new cos.COSServiceFactory(); + const runWithExitSpan = jest.fn(async (_name, _options, callback) => { + return callback(); + }); + (factory as any).traceService = { runWithExitSpan }; + + const client: any = await factory.createClient({ + customClientClass: CustomCOS, + SecretId: 'test', + SecretKey: 'secret', + } as any); + + expect(await client.request({ Action: 'GetObject' })).toBe('GetObject'); + expect(runWithExitSpan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/etcd/src/manager.ts b/packages/etcd/src/manager.ts index b8f9a73c7f6b..a16d04be060c 100644 --- a/packages/etcd/src/manager.ts +++ b/packages/etcd/src/manager.ts @@ -42,6 +42,13 @@ export class ETCDServiceFactory extends ServiceFactory { protected traceInjector; async createClient(config: IOptions): Promise { + const { customClientClass, ...otherConfig } = config as any; + if (customClientClass) { + const client = new customClientClass(otherConfig); + this.bindTraceContext(client as any); + return client; + } + this.logger.info('[midway:etcd] init %s', config.hosts); const client = new Etcd3(config); this.bindTraceContext(client); diff --git a/packages/etcd/test/customClient.test.ts b/packages/etcd/test/customClient.test.ts new file mode 100644 index 000000000000..599c11f9446b --- /dev/null +++ b/packages/etcd/test/customClient.test.ts @@ -0,0 +1,87 @@ +import { close, createLightApp } from '@midwayjs/mock'; +import * as etcd from '../src'; + +describe('/test/customClient.test.ts', () => { + class CustomEtcd3 { + public pool = { + exec: jest.fn(async (_service, method) => method), + }; + + constructor(public config: any) {} + + async close() {} + } + + it('should create a custom client from client config', async () => { + const app = await createLightApp('', { + imports: [etcd], + globalConfig: { + etcd: { + client: { + customClientClass: CustomEtcd3, + hosts: ['default.local:2379'], + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(etcd.ETCDServiceFactory); + + expect(factory.get()).toBeInstanceOf(CustomEtcd3); + + await close(app); + }); + + it('should create custom clients and keep factory/service injection', async () => { + const app = await createLightApp('', { + imports: [etcd], + globalConfig: { + etcd: { + clients: { + default: { + customClientClass: CustomEtcd3, + hosts: ['default.local:2379'], + }, + backup: { + customClientClass: CustomEtcd3, + hosts: ['backup.local:2379'], + }, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(etcd.ETCDServiceFactory); + const service = await app.getApplicationContext().getAsync(etcd.ETCDService); + const defaultClient = factory.get(); + const backupClient = factory.get('backup'); + + expect(defaultClient).toBeInstanceOf(CustomEtcd3); + expect(backupClient).toBeInstanceOf(CustomEtcd3); + expect(defaultClient.config.customClientClass).toBeUndefined(); + expect(backupClient.config.hosts).toEqual(['backup.local:2379']); + expect((service as any).instance).toBe(defaultClient); + + await close(app); + }); + + it('should bind trace context to custom client', async () => { + const factory = new etcd.ETCDServiceFactory(); + const runWithExitSpan = jest.fn(async (_name, _options, callback) => { + return callback(); + }); + (factory as any).traceService = { runWithExitSpan }; + + const client: any = await factory.createClient({ + customClientClass: CustomEtcd3, + hosts: ['localhost:2379'], + } as any); + + expect(await client.pool.exec('KV', 'put', {})).toBe('put'); + expect(runWithExitSpan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/oss/src/manager.ts b/packages/oss/src/manager.ts index fc06127fce57..2c76b13982ec 100644 --- a/packages/oss/src/manager.ts +++ b/packages/oss/src/manager.ts @@ -59,6 +59,13 @@ export class OSSServiceFactory< async createClient( config: OSSServiceFactoryCreateClientConfigType ): Promise { + const { customClientClass, ...otherConfig } = config as any; + if (customClientClass) { + const client = new customClientClass(otherConfig); + this.bindTraceContext(client as any); + return client; + } + if (config['cluster'] && !config.clusters) { config.clusters = config['cluster']; } diff --git a/packages/oss/test/customClient.test.ts b/packages/oss/test/customClient.test.ts new file mode 100644 index 000000000000..eaeaeb112b39 --- /dev/null +++ b/packages/oss/test/customClient.test.ts @@ -0,0 +1,89 @@ +import { close, createLightApp } from '@midwayjs/mock'; +import * as oss from '../src'; + +describe('/test/customClient.test.ts', () => { + class CustomOSS { + constructor(public config: any) {} + + request(options: any) { + return Promise.resolve(options.method); + } + } + + it('should create a custom client from client config', async () => { + const app = await createLightApp('', { + imports: [oss], + globalConfig: { + oss: { + client: { + customClientClass: CustomOSS, + accessKeyId: 'default-id', + accessKeySecret: 'secret', + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(oss.OSSServiceFactory); + + expect(factory.get()).toBeInstanceOf(CustomOSS); + + await close(app); + }); + + it('should create custom clients and keep factory/service injection', async () => { + const app = await createLightApp('', { + imports: [oss], + globalConfig: { + oss: { + clients: { + default: { + customClientClass: CustomOSS, + accessKeyId: 'default-id', + accessKeySecret: 'secret', + }, + backup: { + customClientClass: CustomOSS, + accessKeyId: 'backup-id', + accessKeySecret: 'secret', + }, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(oss.OSSServiceFactory); + const service = await app.getApplicationContext().getAsync(oss.OSSService); + const defaultClient = factory.get(); + const backupClient = factory.get('backup'); + + expect(defaultClient).toBeInstanceOf(CustomOSS); + expect(backupClient).toBeInstanceOf(CustomOSS); + expect(defaultClient.config.customClientClass).toBeUndefined(); + expect(backupClient.config.accessKeyId).toBe('backup-id'); + expect((service as any).instance).toBe(defaultClient); + + await close(app); + }); + + it('should bind trace context to custom client', async () => { + const factory = new oss.OSSServiceFactory(); + const runWithExitSpan = jest.fn(async (_name, _options, callback) => { + return callback(); + }); + (factory as any).traceService = { runWithExitSpan }; + + const client: any = await factory.createClient({ + customClientClass: CustomOSS, + accessKeyId: 'test', + accessKeySecret: 'secret', + } as any); + + expect(await client.request({ method: 'PUT' })).toBe('PUT'); + expect(runWithExitSpan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/redis/src/manager.ts b/packages/redis/src/manager.ts index 04ab3a6a68c8..b230ca275011 100644 --- a/packages/redis/src/manager.ts +++ b/packages/redis/src/manager.ts @@ -49,44 +49,51 @@ export class RedisServiceFactory extends ServiceFactory { protected async createClient(config, name: string): Promise { let client; - if (config.cluster === true) { - assert.ok( - config.nodes && config.nodes.length !== 0, - `[midway:redis] client(${name}) cluster nodes configuration is required when use cluster redis` - ); - - config.nodes.forEach(client => { + const { customClientClass, ...otherConfig } = config; + if (customClientClass) { + client = new customClientClass(otherConfig); + } else { + if (config.cluster === true) { assert.ok( - client.host && client.port, - `[midway:redis] client(${name}) 'host: ${client.host}', 'port: ${client.port}' are required on config` + config.nodes && config.nodes.length !== 0, + `[midway:redis] client(${name}) cluster nodes configuration is required when use cluster redis` ); - }); - client = new Redis.Cluster(config.nodes, config); - this.logger.info('[midway:redis] cluster is connecting'); - } else if (config.sentinels) { - assert.ok( - config.sentinels && config.sentinels.length !== 0, - `[midway:redis] client(${name}) sentinels configuration is required when use redis sentinel` - ); - config.sentinels.forEach(sentinel => { + config.nodes.forEach(client => { + assert.ok( + client.host && client.port, + `[midway:redis] client(${name}) 'host: ${client.host}', 'port: ${client.port}' are required on config` + ); + }); + client = new Redis.Cluster(config.nodes, config); + this.logger.info('[midway:redis] cluster is connecting'); + } else if (config.sentinels) { assert.ok( - sentinel.host && sentinel.port, - `[midway:redis] client(${name}) 'host: ${sentinel.host}', 'port: ${sentinel.port}' are required on config` + config.sentinels && config.sentinels.length !== 0, + `[midway:redis] client(${name}) sentinels configuration is required when use redis sentinel` ); - }); - client = new Redis(config); - this.logger.info(`[midway:redis] client(${name}) sentinel is connecting`); - } else { - assert.ok( - config.host && config.port, - `[midway:redis] client(${name}) 'host: ${config.host}', 'port: ${config.port}' are required on config` - ); - client = new Redis(config); - this.logger.info( - `[midway:redis] client(${name}) server is connecting redis://:***@${config.host}:${config.port}` - ); + config.sentinels.forEach(sentinel => { + assert.ok( + sentinel.host && sentinel.port, + `[midway:redis] client(${name}) 'host: ${sentinel.host}', 'port: ${sentinel.port}' are required on config` + ); + }); + + client = new Redis(config); + this.logger.info( + `[midway:redis] client(${name}) sentinel is connecting` + ); + } else { + assert.ok( + config.host && config.port, + `[midway:redis] client(${name}) 'host: ${config.host}', 'port: ${config.port}' are required on config` + ); + client = new Redis(config); + this.logger.info( + `[midway:redis] client(${name}) server is connecting redis://:***@${config.host}:${config.port}` + ); + } } await new Promise((resolve, reject) => { diff --git a/packages/redis/test/customClient.test.ts b/packages/redis/test/customClient.test.ts new file mode 100644 index 000000000000..e1c76dd09655 --- /dev/null +++ b/packages/redis/test/customClient.test.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from 'events'; +import { close, createLightApp } from '@midwayjs/mock'; +import * as redis from '../src'; + +describe('/test/customClient.test.ts', () => { + class CustomRedis extends EventEmitter { + status = 'connecting'; + + constructor(public config: any) { + super(); + queueMicrotask(() => { + this.status = 'ready'; + this.emit('ready'); + }); + } + + sendCommand(command: any) { + return Promise.resolve(command?.name); + } + + async get() { + return 'custom'; + } + + async quit() { + this.status = 'end'; + } + } + + it('should create a custom client from client config', async () => { + const app = await createLightApp('', { + imports: [redis], + globalConfig: { + redis: { + client: { + customClientClass: CustomRedis, + host: '127.0.0.1', + port: 6379, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(redis.RedisServiceFactory); + + expect(factory.get()).toBeInstanceOf(CustomRedis); + + await close(app); + }); + + it('should create custom clients and keep factory/service injection', async () => { + const app = await createLightApp('', { + imports: [redis], + globalConfig: { + redis: { + clients: { + default: { + customClientClass: CustomRedis, + host: '127.0.0.1', + port: 6379, + }, + cache: { + customClientClass: CustomRedis, + host: '127.0.0.1', + port: 6380, + }, + }, + }, + }, + }); + + const factory = await app + .getApplicationContext() + .getAsync(redis.RedisServiceFactory); + const service = await app.getApplicationContext().getAsync(redis.RedisService); + const defaultClient = factory.get(); + const cacheClient = factory.get('cache'); + + expect(defaultClient).toBeInstanceOf(CustomRedis); + expect(cacheClient).toBeInstanceOf(CustomRedis); + expect(defaultClient.config.customClientClass).toBeUndefined(); + expect(cacheClient.config.port).toBe(6380); + expect(await service.get('key')).toBe('custom'); + + await close(app); + }); + + it('should reject when custom client emits error', async () => { + class ErrorRedis extends EventEmitter { + status = 'connecting'; + + constructor(config: any) { + super(); + expect(config.customClientClass).toBeUndefined(); + queueMicrotask(() => this.emit('error', new Error('connection failed'))); + } + + async quit() {} + } + + await expect( + createLightApp('', { + imports: [redis], + globalConfig: { + redis: { + client: { + customClientClass: ErrorRedis, + host: '127.0.0.1', + port: 6379, + }, + }, + }, + }) + ).rejects.toThrow('connection failed'); + }); + + it('should bind trace context to custom client', async () => { + const factory = new redis.RedisServiceFactory(); + const runWithExitSpan = jest.fn(async (_name, _options, callback) => { + return callback(); + }); + (factory as any).traceService = { runWithExitSpan }; + (factory as any).logger = { info: jest.fn(), error: jest.fn() }; + + const client: any = await (factory as any).createClient( + { + customClientClass: CustomRedis, + host: '127.0.0.1', + port: 6379, + }, + 'default' + ); + + expect(await client.sendCommand({ name: 'GET' })).toBe('GET'); + expect(runWithExitSpan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sequelize/src/dataSourceManager.ts b/packages/sequelize/src/dataSourceManager.ts index 6c6da73db461..5c3de25a048e 100644 --- a/packages/sequelize/src/dataSourceManager.ts +++ b/packages/sequelize/src/dataSourceManager.ts @@ -34,7 +34,14 @@ export class SequelizeDataSourceManager extends DataSourceManager { config: any, dataSourceName: string ): Promise { - const client = new Sequelize(config); + let client: Sequelize; + const { customDataSourceClass, ...otherConfig } = config; + if (customDataSourceClass) { + client = new customDataSourceClass(otherConfig); + } else { + client = new Sequelize(otherConfig); + } + const entities = config['entities']; if (entities && entities.length > 0) { client.addModels(entities); diff --git a/packages/sequelize/test/customDataSource.test.ts b/packages/sequelize/test/customDataSource.test.ts new file mode 100644 index 000000000000..15dfe4358892 --- /dev/null +++ b/packages/sequelize/test/customDataSource.test.ts @@ -0,0 +1,104 @@ +import { close, createLightApp } from '@midwayjs/mock'; +import * as sequelize from '../src'; + +describe('/test/customDataSource.test.ts', () => { + class CustomSequelize { + public static configs: any[] = []; + public addModels = jest.fn(); + public sync = jest.fn(); + + constructor(public config: any) { + CustomSequelize.configs.push(config); + } + + async authenticate() { + return true; + } + + async close() {} + } + + beforeEach(() => { + CustomSequelize.configs = []; + }); + + it('should create a custom data source from merged default config', async () => { + const app = await createLightApp({ + imports: [sequelize], + globalConfig: { + sequelize: { + default: { + customDataSourceClass: CustomSequelize, + }, + dataSource: { + default: { + dialect: 'sqlite', + }, + }, + }, + }, + }); + + const manager = await app + .getApplicationContext() + .getAsync(sequelize.SequelizeDataSourceManager); + const defaultDataSource = manager.getDataSource( + 'default' + ) as unknown as CustomSequelize; + + expect(defaultDataSource).toBeInstanceOf(CustomSequelize); + expect(CustomSequelize.configs).toHaveLength(1); + expect(CustomSequelize.configs[0].customDataSourceClass).toBeUndefined(); + expect(CustomSequelize.configs[0].dialect).toBe('sqlite'); + + await close(app); + }); + + it('should create custom data sources using customDataSourceClass', async () => { + class User {} + const syncOptions = { + force: true, + }; + const app = await createLightApp({ + imports: [sequelize], + globalConfig: { + sequelize: { + dataSource: { + default: { + customDataSourceClass: CustomSequelize, + dialect: 'sqlite', + }, + reporting: { + customDataSourceClass: CustomSequelize, + dialect: 'sqlite', + entities: [User], + sync: true, + syncOptions, + }, + }, + }, + }, + }); + + const manager = await app + .getApplicationContext() + .getAsync(sequelize.SequelizeDataSourceManager); + const defaultDataSource = manager.getDataSource( + 'default' + ) as unknown as CustomSequelize; + const reportingDataSource = manager.getDataSource( + 'reporting' + ) as unknown as CustomSequelize; + + expect(defaultDataSource).toBeInstanceOf(CustomSequelize); + expect(reportingDataSource).toBeInstanceOf(CustomSequelize); + expect(CustomSequelize.configs).toHaveLength(2); + expect( + CustomSequelize.configs.every(config => !config.customDataSourceClass) + ).toBe(true); + expect(reportingDataSource.addModels).toHaveBeenCalledWith([User]); + expect(reportingDataSource.sync).toHaveBeenCalledWith(syncOptions); + + await close(app); + }); +});