feat(core): repl

This commit is contained in:
Kamil Myśliwiec
2022-05-29 16:08:15 +02:00
parent e322564fd8
commit 245ccd1249
19 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
import { clc } from '@nestjs/common/utils/cli-colors.util';
import { repl } from '@nestjs/core';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { AppModule } from '../src/app.module';
import { UsersModule } from '../src/users/users.module';
const prompt = '\u001b[1G\u001b[0J\u001b[32m>\u001b[0m \u001b[3G';
describe('REPL', () => {
beforeEach(() => {
sinon.stub(clc, 'yellow').callsFake(text => text);
sinon.stub(clc, 'green').callsFake(text => text);
});
afterEach(() => {
sinon.restore();
delete globalThis[AppModule.name];
delete globalThis[UsersModule.name];
});
it('get()', async () => {
const server = await repl(AppModule);
let outputText = '';
sinon.stub(process.stdout, 'write').callsFake(text => {
outputText += text;
return true;
});
server.emit('line', 'get(UsersService)');
expect(outputText).to.equal(
`UsersService { usersRepository: UsersRepository {} }
${prompt}`,
);
outputText = '';
server.emit('line', 'get(UsersService).findAll()');
expect(outputText).to
.equal(`\u001b[32m'This action returns all users'\u001b[39m
${prompt}`);
outputText = '';
server.emit('line', 'get(UsersRepository)');
expect(outputText).to.equal(`UsersRepository {}
${prompt}`);
});
it('debug()', async () => {
const server = await repl(AppModule);
let outputText = '';
sinon.stub(process.stdout, 'write').callsFake(text => {
outputText += text;
return true;
});
server.emit('line', 'debug(UsersModule)');
expect(outputText).to.equal(
`
UsersModule:
- controllers:
◻ UsersController
- providers:
◻ UsersService
◻ UsersRepository
${prompt}`,
);
});
it('methods()', async () => {
const server = await repl(AppModule);
let outputText = '';
sinon.stub(process.stdout, 'write').callsFake(text => {
outputText += text;
return true;
});
server.emit('line', 'methods(UsersRepository)');
expect(outputText).to.equal(
`
Methods:
◻ find
${prompt}`,
);
outputText = '';
server.emit('line', 'methods(UsersService)');
expect(outputText).to.equal(
`
Methods:
◻ create
◻ findAll
◻ findOne
◻ update
◻ remove
${prompt}`,
);
});
});

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}

View File

@@ -0,0 +1 @@
export class CreateUserDto {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -0,0 +1 @@
export class User {}

View File

@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
let controller: UsersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersService],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [
UsersService,
{
provide: UsersRepository.name,
useValue: new UsersRepository(),
},
],
})
export class UsersModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersRepository {
async find() {
return [{ id: 1, email: 'test@nestjs.com' }];
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersRepository } from './users.repository';
@Injectable()
export class UsersService {
constructor(
@Inject('UsersRepository')
private readonly usersRepository: UsersRepository,
) {}
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
}
findAll() {
return `This action returns all users`;
}
findOne(id: number) {
return `This action returns a #${id} user`;
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
}
remove(id: number) {
return `This action removes a #${id} user`;
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"noImplicitAny": false,
"removeComments": true,
"noLib": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"allowJs": true,
"outDir": "./dist"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}

View File

@@ -18,5 +18,6 @@ export * from './middleware';
export * from './nest-application';
export * from './nest-application-context';
export { NestFactory } from './nest-factory';
export * from './repl';
export * from './router';
export * from './services';

View File

@@ -0,0 +1 @@
export const REPL_INITIALIZED_MESSAGE = 'REPL initialized';

View File

@@ -0,0 +1 @@
export * from './repl';

View File

@@ -0,0 +1,167 @@
import {
DynamicModule,
INestApplication,
InjectionToken,
Logger,
Type,
} from '@nestjs/common';
import { clc } from '@nestjs/common/utils/cli-colors.util';
import { ApplicationConfig } from '../application-config';
import { ModuleRef, NestContainer } from '../injector';
import { InternalCoreModule } from '../injector/internal-core-module';
import { Module } from '../injector/module';
import { MetadataScanner } from '../metadata-scanner';
type ModuleKey = string;
type ModuleDebugEntry = {
controllers: Record<string, InjectionToken>;
providers: Record<string, InjectionToken>;
};
export class ReplContext {
private debugRegistry: Record<ModuleKey, ModuleDebugEntry> = {};
private readonly container: NestContainer;
private readonly logger = new Logger(ReplContext.name);
private readonly metadataScanner = new MetadataScanner();
constructor(private readonly app: INestApplication) {
this.container = (app as any).container;
this.initialize();
}
$(token: string | symbol | Function | Type<any>) {
return this.get(token);
}
get(token: string | symbol | Function | Type<any>) {
return this.app.get(token);
}
resolve(token: string | symbol | Function | Type<any>, contextId: any) {
return this.app.resolve(token, contextId);
}
select(token: DynamicModule | Type<unknown>) {
return this.app.select(token);
}
debug(moduleCls?: Type | string) {
this.writeToStdout('\n');
if (moduleCls) {
const token =
typeof moduleCls === 'function' ? moduleCls.name : moduleCls;
const moduleEntry = this.debugRegistry[token];
if (!moduleEntry) {
return this.logger.error(
`"${token}" has not been found in the modules registry`,
);
}
this.printCtrlsAndProviders(token, moduleEntry);
} else {
Object.keys(this.debugRegistry).forEach(moduleKey => {
this.printCtrlsAndProviders(moduleKey, this.debugRegistry[moduleKey]);
});
}
this.writeToStdout('\n');
}
methods(token: Type | string) {
const proto =
typeof token !== 'function'
? Object.getPrototypeOf(this.app.get(token))
: token?.prototype;
const methods = new Set(
this.metadataScanner.getAllFilteredMethodNames(proto),
);
this.writeToStdout('\n');
this.writeToStdout(`${clc.green('Methods')}: \n`);
methods.forEach(methodName =>
this.writeToStdout(` ${clc.yellow('◻')} ${methodName}\n`),
);
this.writeToStdout('\n');
}
private initialize() {
const globalRef = globalThis;
const modules = this.container.getModules();
modules.forEach(moduleRef => {
let moduleName = moduleRef.metatype.name;
if (moduleName === InternalCoreModule.name) {
return;
}
if (globalRef[moduleName]) {
moduleName += ` (${moduleRef.token})`;
}
this.introspectCollection(moduleRef, moduleName, 'providers');
this.introspectCollection(moduleRef, moduleName, 'controllers');
globalRef[moduleName] = moduleRef.metatype;
});
}
private introspectCollection(
moduleRef: Module,
moduleKey: ModuleKey,
collection: keyof ModuleDebugEntry,
) {
let moduleDebugEntry = {};
moduleRef[collection].forEach(({ token }) => {
const stringifiedToken = this.stringifyToken(token);
if (
stringifiedToken === ApplicationConfig.name ||
stringifiedToken === moduleRef.metatype.name
) {
return;
}
// For in REPL auto-complete functionality
globalThis[stringifiedToken] = token;
if (stringifiedToken === ModuleRef.name) {
return;
}
moduleDebugEntry[stringifiedToken] = token;
});
this.debugRegistry[moduleKey] = {
...this.debugRegistry?.[moduleKey],
[collection]: moduleDebugEntry,
};
}
private stringifyToken(token: unknown): string {
return typeof token !== 'string'
? typeof token === 'function'
? token.name
: token?.toString()
: token;
}
private printCtrlsAndProviders(
moduleName: string,
moduleDebugEntry: ModuleDebugEntry,
) {
const printCollection = (collection: keyof ModuleDebugEntry) => {
const collectionEntries = Object.keys(moduleDebugEntry[collection]);
if (collectionEntries.length <= 0) {
return;
}
this.writeToStdout(` ${clc.yellow(`- ${collection}`)}: \n`);
collectionEntries.forEach(provider =>
this.writeToStdout(` ${clc.green('◻')} ${provider}\n`),
);
};
this.writeToStdout(`${clc.green(moduleName)}: \n`);
printCollection('controllers');
printCollection('providers');
}
private writeToStdout(text: string) {
process.stdout.write(text);
}
}

View File

@@ -0,0 +1,18 @@
import { ConsoleLogger } from '@nestjs/common';
import { NestApplication } from '../nest-application';
import { RouterExplorer } from '../router/router-explorer';
import { RoutesResolver } from '../router/routes-resolver';
export class ReplLogger extends ConsoleLogger {
private static readonly ignoredContexts = [
RoutesResolver.name,
RouterExplorer.name,
NestApplication.name,
];
log(_message: any, context?: string) {
if (ReplLogger.ignoredContexts.includes(context)) {
return;
}
return super.log.apply(this, Array.from(arguments) as [any, string?]);
}
}

View File

@@ -0,0 +1,31 @@
import { Logger, Type } from '@nestjs/common';
import * as _repl from 'repl';
import { NestFactory } from '../nest-factory';
import { REPL_INITIALIZED_MESSAGE } from './constants';
import { ReplContext } from './repl-context';
import { ReplLogger } from './repl-logger';
export async function repl(module: Type) {
const app = await NestFactory.create(module, {
abortOnError: false,
logger: new ReplLogger(),
});
await app.init();
const replContext = new ReplContext(app);
Logger.log(REPL_INITIALIZED_MESSAGE);
const replServer = _repl.start({
prompt: '\x1b[32m>\x1b[0m ',
ignoreUndefined: true,
});
replServer.context.$ = replContext.$.bind(replContext);
replServer.context.get = replContext.get.bind(replContext);
replServer.context.resolve = replContext.resolve.bind(replContext);
replServer.context.select = replContext.select.bind(replContext);
replServer.context.debug = replContext.debug.bind(replContext);
replServer.context.methods = replContext.methods.bind(replContext);
return replServer;
}

View File

@@ -0,0 +1,185 @@
import { clc } from '@nestjs/common/utils/cli-colors.util';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { NestContainer } from '../../injector/container';
import { ReplContext } from '../../repl/repl-context';
describe('ReplContext', () => {
let replContext: ReplContext;
let mockApp: {
container: NestContainer;
get: sinon.SinonStub;
resolve: sinon.SinonSpy;
select: sinon.SinonSpy;
};
before(async () => {
const container = new NestContainer();
const aModuleRef = await container.addModule(class ModuleA {}, []);
const bModuleRef = await container.addModule(class ModuleB {}, []);
container.addController(class ControllerA {}, aModuleRef.token);
container.addProvider(class ProviderA1 {}, aModuleRef.token);
container.addProvider(class ProviderA2 {}, aModuleRef.token);
container.addProvider(class ProviderB1 {}, bModuleRef.token);
container.addProvider(class ProviderB2 {}, bModuleRef.token);
mockApp = {
container,
get: sinon.stub(),
resolve: sinon.spy(),
select: sinon.spy(),
};
replContext = new ReplContext(mockApp as any);
});
beforeEach(() => {
sinon.stub(clc, 'yellow').callsFake(text => text);
sinon.stub(clc, 'green').callsFake(text => text);
});
afterEach(() => sinon.restore());
describe('debug', () => {
it('should print all modules along with their controllers and providers', () => {
let outputText = '';
sinon
.stub(replContext as any, 'writeToStdout')
.callsFake(text => (outputText += text));
replContext.debug();
expect(outputText).to.equal(`
ModuleA:
- controllers:
◻ ControllerA
- providers:
◻ ProviderA1
◻ ProviderA2
ModuleB:
- providers:
◻ ProviderB1
◻ ProviderB2
`);
});
describe('when module passed as a class reference', () => {
it("should print a specified module's controllers and providers", () => {
let outputText = '';
sinon
.stub(replContext as any, 'writeToStdout')
.callsFake(text => (outputText += text));
replContext.debug(class ModuleA {});
expect(outputText).to.equal(`
ModuleA:
- controllers:
◻ ControllerA
- providers:
◻ ProviderA1
◻ ProviderA2
`);
});
});
describe("when module passed as a string (module's key)", () => {
it("should print a specified module's controllers and providers", () => {
let outputText = '';
sinon
.stub(replContext as any, 'writeToStdout')
.callsFake(text => (outputText += text));
replContext.debug('ModuleA');
expect(outputText).to.equal(`
ModuleA:
- controllers:
◻ ControllerA
- providers:
◻ ProviderA1
◻ ProviderA2
`);
});
});
});
describe('methods', () => {
describe('when token is a class reference', () => {
it('should print all class methods', () => {
class BaseService {
create() {}
}
class TestService extends BaseService {
findAll() {}
findOne() {}
}
let outputText = '';
sinon
.stub(replContext as any, 'writeToStdout')
.callsFake(text => (outputText += text));
replContext.methods(TestService);
expect(outputText).to.equal(`
Methods:
◻ findAll
◻ findOne
◻ create
`);
});
});
describe('when token is a string', () => {
it('should grab provider from the container and print its all methods', () => {
class ProviderA1 {
findAll() {}
findOne() {}
}
let outputText = '';
sinon
.stub(replContext as any, 'writeToStdout')
.callsFake(text => (outputText += text));
mockApp.get.callsFake(() => new ProviderA1());
replContext.methods('ProviderA1');
expect(outputText).to.equal(`
Methods:
◻ findAll
◻ findOne
`);
});
});
});
describe('get', () => {
it('should pass arguments down to the application context', () => {
const token = 'test';
replContext.get(token);
expect(mockApp.get.calledWith(token)).to.be.true;
});
});
describe('resolve', () => {
it('should pass arguments down to the application context', async () => {
const token = 'test';
const contextId = {};
await replContext.resolve(token, contextId);
expect(mockApp.resolve.calledWith(token, contextId)).to.be.true;
});
});
describe('select', () => {
it('should pass arguments down to the application context', () => {
const moduleCls = class TestModule {};
replContext.select(moduleCls);
expect(mockApp.select.calledWith(moduleCls)).to.be.true;
});
});
});