diff --git a/.prettierignore b/.prettierignore index 0cddabc39899..f80d42116d61 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ /docs/apidocs /docs/site/apidocs packages/cli/generators/*/templates +packages/cli/snapshots/ packages/tsdocs/fixtures/monorepo/docs packages/tsdocs/fixtures/monorepo/**/dist packages/tsdocs/fixtures/monorepo/**/docs diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index 5db6292fb93b..598e62e4b4f4 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -976,6 +976,18 @@ "unique-string": "^1.0.0", "write-file-atomic": "^2.0.0", "xdg-basedir": "^3.0.0" + }, + "dependencies": { + "write-file-atomic": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + } } }, "constant-case": { @@ -2344,8 +2356,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-unc-path": { "version": "1.0.0", @@ -3258,6 +3269,11 @@ "to-regex": "^3.0.1" } }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -5097,6 +5113,14 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "requires": { + "is-typedarray": "^1.0.0" + } + }, "typescript": { "version": "3.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.3.tgz", @@ -5493,13 +5517,14 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", - "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.0.tgz", + "integrity": "sha512-EIgkf60l2oWsffja2Sf2AL384dx328c0B+cIYPTQq5q2rOYuDV00/iPFBOUiDKKwKMOhkymH8AidPaRvzfxY+Q==", "requires": { - "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.2" + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" } }, "xdg-basedir": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 9c62fcac5a43..9d6ce40e4967 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,6 +51,8 @@ "json5": "^2.1.0", "lodash": "^4.17.15", "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", "pacote": "^9.5.8", "pluralize": "^8.0.0", "regenerate": "^1.4.0", @@ -66,6 +68,7 @@ "update-notifier": "^3.0.1", "url-slug": "^2.1.3", "validate-npm-package-name": "^3.0.0", + "write-file-atomic": "^3.0.0", "yeoman-generator": "^4.0.2" }, "scripts": { diff --git a/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js b/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js new file mode 100644 index 000000000000..5c4663f15cc8 --- /dev/null +++ b/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js @@ -0,0 +1,371 @@ +// IMPORTANT +// This snapshot file is auto-generated, but designed for humans. +// It should be checked into source control and tracked carefully. +// Re-generate by setting UPDATE_SNAPSHOTS=1 and running tests. +// Make sure to inspect the changes in the snapshots below. +// Do not ignore changes! + +'use strict'; + +exports[`lb4 controller REST CRUD controller creates REST CRUD template with valid input - id omitted 1`] = ` +import { + Count, + CountSchema, + Filter, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getFilterSchemaFor, + getModelSchemaRef, + getWhereSchemaFor, + patch, + put, + del, + requestBody, +} from '@loopback/rest'; +import {ProductReview} from '../models'; +import {BarRepository} from '../repositories'; + +export class ProductReviewController { + constructor( + @repository(BarRepository) + public barRepository : BarRepository, + ) {} + + @post('/product-reviews', { + responses: { + '200': { + description: 'ProductReview model instance', + content: {'application/json': {schema: getModelSchemaRef(ProductReview)}}, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, { + title: 'NewProductReview', + exclude: ['productId'], + }), + }, + }, + }) + productReview: Omit, + ): Promise { + return this.barRepository.create(productReview); + } + + @get('/product-reviews/count', { + responses: { + '200': { + description: 'ProductReview model count', + content: {'application/json': {schema: CountSchema}}, + }, + }, + }) + async count( + @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + ): Promise { + return this.barRepository.count(where); + } + + @get('/product-reviews', { + responses: { + '200': { + description: 'Array of ProductReview model instances', + content: { + 'application/json': { + schema: {type: 'array', items: getModelSchemaRef(ProductReview)}, + }, + }, + }, + }, + }) + async find( + @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter, + ): Promise { + return this.barRepository.find(filter); + } + + @patch('/product-reviews', { + responses: { + '200': { + description: 'ProductReview PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }, + }, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, {partial: true}), + }, + }, + }) + productReview: ProductReview, + @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + ): Promise { + return this.barRepository.updateAll(productReview, where); + } + + @get('/product-reviews/{id}', { + responses: { + '200': { + description: 'ProductReview model instance', + content: {'application/json': {schema: getModelSchemaRef(ProductReview)}}, + }, + }, + }) + async findById(@param.path.number('id') id: number): Promise { + return this.barRepository.findById(id); + } + + @patch('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview PATCH success', + }, + }, + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, {partial: true}), + }, + }, + }) + productReview: ProductReview, + ): Promise { + await this.barRepository.updateById(id, productReview); + } + + @put('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview PUT success', + }, + }, + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() productReview: ProductReview, + ): Promise { + await this.barRepository.replaceById(id, productReview); + } + + @del('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview DELETE success', + }, + }, + }) + async deleteById(@param.path.number('id') id: number): Promise { + await this.barRepository.deleteById(id); + } +} + +`; + + +exports[`lb4 controller REST CRUD controller creates REST CRUD template with valid input 1`] = ` +import { + Count, + CountSchema, + Filter, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getFilterSchemaFor, + getModelSchemaRef, + getWhereSchemaFor, + patch, + put, + del, + requestBody, +} from '@loopback/rest'; +import {ProductReview} from '../models'; +import {BarRepository} from '../repositories'; + +export class ProductReviewController { + constructor( + @repository(BarRepository) + public barRepository : BarRepository, + ) {} + + @post('/product-reviews', { + responses: { + '200': { + description: 'ProductReview model instance', + content: {'application/json': {schema: getModelSchemaRef(ProductReview)}}, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, { + title: 'NewProductReview', + + }), + }, + }, + }) + productReview: ProductReview, + ): Promise { + return this.barRepository.create(productReview); + } + + @get('/product-reviews/count', { + responses: { + '200': { + description: 'ProductReview model count', + content: {'application/json': {schema: CountSchema}}, + }, + }, + }) + async count( + @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + ): Promise { + return this.barRepository.count(where); + } + + @get('/product-reviews', { + responses: { + '200': { + description: 'Array of ProductReview model instances', + content: { + 'application/json': { + schema: {type: 'array', items: getModelSchemaRef(ProductReview)}, + }, + }, + }, + }, + }) + async find( + @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter, + ): Promise { + return this.barRepository.find(filter); + } + + @patch('/product-reviews', { + responses: { + '200': { + description: 'ProductReview PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }, + }, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, {partial: true}), + }, + }, + }) + productReview: ProductReview, + @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + ): Promise { + return this.barRepository.updateAll(productReview, where); + } + + @get('/product-reviews/{id}', { + responses: { + '200': { + description: 'ProductReview model instance', + content: {'application/json': {schema: getModelSchemaRef(ProductReview)}}, + }, + }, + }) + async findById(@param.path.number('id') id: number): Promise { + return this.barRepository.findById(id); + } + + @patch('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview PATCH success', + }, + }, + }) + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(ProductReview, {partial: true}), + }, + }, + }) + productReview: ProductReview, + ): Promise { + await this.barRepository.updateById(id, productReview); + } + + @put('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview PUT success', + }, + }, + }) + async replaceById( + @param.path.number('id') id: number, + @requestBody() productReview: ProductReview, + ): Promise { + await this.barRepository.replaceById(id, productReview); + } + + @del('/product-reviews/{id}', { + responses: { + '204': { + description: 'ProductReview DELETE success', + }, + }, + }) + async deleteById(@param.path.number('id') id: number): Promise { + await this.barRepository.deleteById(id); + } +} + +`; + + +exports[`lb4 controller basic controller scaffolds correct file with args 1`] = ` +// Uncomment these imports to begin using these cool features! + +// import {inject} from '@loopback/context'; + + +export class ProductReviewController { + constructor() {} +} + +`; + + +exports[`lb4 controller basic controller scaffolds correct file with input 1`] = ` +// Uncomment these imports to begin using these cool features! + +// import {inject} from '@loopback/context'; + + +export class ProductReviewController { + constructor() {} +} + +`; diff --git a/packages/cli/snapshots/integration/generators/datasource.integration.snapshots.js b/packages/cli/snapshots/integration/generators/datasource.integration.snapshots.js new file mode 100644 index 000000000000..1d6aad76aff4 --- /dev/null +++ b/packages/cli/snapshots/integration/generators/datasource.integration.snapshots.js @@ -0,0 +1,212 @@ +// IMPORTANT +// This snapshot file is auto-generated, but designed for humans. +// It should be checked into source control and tracked carefully. +// Re-generate by setting UPDATE_SNAPSHOTS=1 and running tests. +// Make sure to inspect the changes in the snapshots below. +// Do not ignore changes! + +'use strict'; + +exports[`lb4 datasource integration basic datasource scaffolds correct file with args 1`] = ` +import { + inject, + lifeCycleObserver, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import * as config from './ds.datasource.json'; + +@lifeCycleObserver('datasource') +export class DsDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'ds'; + + constructor( + @inject('datasources.config.ds', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } + + /** + * Start the datasource when application is started + */ + start(): ValueOrPromise { + // Add your logic here to be invoked when the application is started + } + + /** + * Disconnect the datasource when application is stopped. This allows the + * application to be shut down gracefully. + */ + stop(): ValueOrPromise { + return super.disconnect(); + } +} + +`; + + +exports[`lb4 datasource integration basic datasource scaffolds correct file with input 1`] = ` +import { + inject, + lifeCycleObserver, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import * as config from './ds.datasource.json'; + +@lifeCycleObserver('datasource') +export class DsDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'ds'; + + constructor( + @inject('datasources.config.ds', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } + + /** + * Start the datasource when application is started + */ + start(): ValueOrPromise { + // Add your logic here to be invoked when the application is started + } + + /** + * Disconnect the datasource when application is stopped. This allows the + * application to be shut down gracefully. + */ + stop(): ValueOrPromise { + return super.disconnect(); + } +} + +`; + + +exports[`lb4 datasource integration correctly coerces setting input of type number 1`] = ` +import { + inject, + lifeCycleObserver, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import * as config from './ds.datasource.json'; + +@lifeCycleObserver('datasource') +export class DsDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'ds'; + + constructor( + @inject('datasources.config.ds', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } + + /** + * Start the datasource when application is started + */ + start(): ValueOrPromise { + // Add your logic here to be invoked when the application is started + } + + /** + * Disconnect the datasource when application is stopped. This allows the + * application to be shut down gracefully. + */ + stop(): ValueOrPromise { + return super.disconnect(); + } +} + +`; + + +exports[`lb4 datasource integration correctly coerces setting input of type object and array 1`] = ` +import { + inject, + lifeCycleObserver, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import * as config from './ds.datasource.json'; + +@lifeCycleObserver('datasource') +export class DsDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'ds'; + + constructor( + @inject('datasources.config.ds', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } + + /** + * Start the datasource when application is started + */ + start(): ValueOrPromise { + // Add your logic here to be invoked when the application is started + } + + /** + * Disconnect the datasource when application is stopped. This allows the + * application to be shut down gracefully. + */ + stop(): ValueOrPromise { + return super.disconnect(); + } +} + +`; + + +exports[`lb4 datasource integration scaffolds correct file with cloudant input 1`] = ` +import { + inject, + lifeCycleObserver, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import * as config from './ds.datasource.json'; + +@lifeCycleObserver('datasource') +export class DsDataSource extends juggler.DataSource + implements LifeCycleObserver { + static dataSourceName = 'ds'; + + constructor( + @inject('datasources.config.ds', {optional: true}) + dsConfig: object = config, + ) { + super(dsConfig); + } + + /** + * Start the datasource when application is started + */ + start(): ValueOrPromise { + // Add your logic here to be invoked when the application is started + } + + /** + * Disconnect the datasource when application is stopped. This allows the + * application to be shut down gracefully. + */ + stop(): ValueOrPromise { + return super.disconnect(); + } +} + +`; diff --git a/packages/cli/test/integration/generators/controller.integration.js b/packages/cli/test/integration/generators/controller.integration.js index 1b26d6ba9e1f..52c9c4d7adc2 100644 --- a/packages/cli/test/integration/generators/controller.integration.js +++ b/packages/cli/test/integration/generators/controller.integration.js @@ -18,6 +18,8 @@ const tests = require('../lib/artifact-generator')(generator); const baseTests = require('../lib/base-generator')(generator); const testUtils = require('../../test-utils'); +const {expectFileToMatchSnapshot} = require('../../snapshots'); + // Test Sandbox const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); const sandbox = new TestSandbox(SANDBOX_PATH); @@ -34,7 +36,7 @@ const restCLIInput = { }; // Expected File Name -const expectedFile = path.join( +const filePath = path.join( SANDBOX_PATH, '/src/controllers/product-review.controller.ts', ); @@ -75,7 +77,6 @@ describe('lb4 controller', () => { .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) .withPrompts(basicCLIInput); - assert.file(expectedFile); checkBasicContents(); }); @@ -85,7 +86,7 @@ describe('lb4 controller', () => { .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) .withArguments('productReview'); - assert.file(expectedFile); + assert.file(filePath); checkBasicContents(); }); }); @@ -240,39 +241,7 @@ describe('lb4 controller', () => { * Helper function to check the contents of a basic controller */ function checkBasicContents() { - assert.fileContent(expectedFile, /class ProductReviewController/); - assert.fileContent(expectedFile, /constructor\(\) {}/); -} - -function checkCreateContentsWithIdOmitted() { - const postCreateRegEx = [ - /\@post\('\/product-reviews', {/, - /responses: {/, - /'200': {/, - /description: 'ProductReview model instance'/, - /content: {'application\/json': {schema: getModelSchemaRef\(ProductReview\)}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async create\(\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {\s+title: 'NewProductReview',\s+exclude: \['productId'\],\s+}\),\s+},\s+},\s+}\)\s+productReview: Omit,\s+\)/, - ]; - postCreateRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); -} - -/** - * Check the contents for operation 'create' when id is not required. - */ -function checkCreateContents() { - const postCreateRegEx = [ - /\@post\('\/product-reviews', {/, - /responses: {/, - /'200': {/, - /description: 'ProductReview model instance'/, - /content: {'application\/json': {schema: getModelSchemaRef\(ProductReview\)}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async create\(\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {\s+title: 'NewProductReview',\s+}\),\s+},\s+},\s+}\)\s+productReview: ProductReview,\s+\)/, - ]; - postCreateRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); + expectFileToMatchSnapshot(filePath); } /** @@ -282,107 +251,7 @@ function checkCreateContents() { * target functions) */ function checkRestCrudContents(options) { - assert.fileContent(expectedFile, /class ProductReviewController/); - - // Repository and injection - assert.fileContent(expectedFile, /\@repository\(BarRepository\)/); - assert.fileContent(expectedFile, /barRepository \: BarRepository/); - - // Assert that the decorators are present in the correct groupings! - // @post - create - if (options && options.idOmitted) { - checkCreateContentsWithIdOmitted(); - } else { - checkCreateContents(); - } - - // @get - count - const getCountRegEx = [ - /\@get\('\/product-reviews\/count', {/, - /responses: {/, - /'200': {/, - /description: 'ProductReview model count'/, - /content: {'application\/json': {schema: CountSchema}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async count\(\s+\@param\.query\.object\('where', getWhereSchemaFor\(ProductReview\)\) where\?: Where(|,\s+)\)/, - ]; - getCountRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @get - find - const getFindRegEx = [ - /\@get\('\/product-reviews', {/, - /responses: {/, - /'200': {/, - /description: 'Array of ProductReview model instances'/, - /content: {'application\/json': {schema: getModelSchemaRef\(ProductReview\)}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async find\(\s*\@param\.query\.object\('filter', getFilterSchemaFor\(ProductReview\)\) filter\?: Filter(|,\s+)\)/, - ]; - getFindRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @patch - updateAll - const patchUpdateAllRegEx = [ - /\@patch\('\/product-reviews', {/, - /responses: {/, - /'200': {/, - /description: 'ProductReview PATCH success count'/, - /content: {'application\/json': {schema: CountSchema}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async updateAll\(\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {partial: true}\),\s+},\s+},\s+}\)\s+productReview: ProductReview,\s{1,} @param\.query\.object\('where', getWhereSchemaFor\(ProductReview\)\) where\?: Where(|,\s+)\)/, - ]; - patchUpdateAllRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @get - findById - const getFindByIdRegEx = [ - /\@get\('\/product-reviews\/{id}', {/, - /responses: {/, - /'200': {/, - /description: 'ProductReview model instance'/, - /content: {'application\/json': {schema: getModelSchemaRef\(ProductReview\)}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async findById\(\@param.path.number\('id'\)/, - ]; - getFindByIdRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @patch - updateById - const patchUpdateByIdRegEx = [ - /\@patch\('\/product-reviews\/{id}'/, - /responses: {/, - /'204': {/, - /description: 'ProductReview PATCH success'/, - /async updateById\(\s+\@param.path.number\('id'\) id: number,\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {partial: true}\),\s+},\s+},\s+}\)\s+productReview: ProductReview,\s+\)/, - ]; - patchUpdateByIdRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @put - replaceById - const putReplaceByIdRegEx = [ - /\@put\('\/product-reviews\/{id}'/, - /responses: {/, - /'204': {/, - /description: 'ProductReview PUT success'/, - /async replaceById\(\s{1,}\@param.path.number\('id'\) id: number,\s{1,}\@requestBody\(\) productReview: ProductReview,\s+\)/, - ]; - putReplaceByIdRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); - - // @del - deleteById - const deleteByIdRegEx = [ - /\@del\('\/product-reviews\/{id}', {/, - /responses: {/, - /'204': {/, - /description: 'ProductReview DELETE success'/, - /async deleteById\(\@param.path.number\('id'\) id: number\)/, - ]; - deleteByIdRegEx.forEach(regex => { - assert.fileContent(expectedFile, regex); - }); + expectFileToMatchSnapshot(filePath); } /** @@ -391,31 +260,31 @@ function checkRestCrudContents(options) { */ function checkRestPaths(restUrl) { assert.fileContent( - expectedFile, + filePath, new RegExp(/@post\('/.source + restUrl + /', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@get\('/.source + restUrl + /\/count', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@get\('/.source + restUrl + /', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@patch\('/.source + restUrl + /', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@get\('/.source + restUrl + /\/{id}', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@patch\('/.source + restUrl + /\/{id}', {/.source), ); assert.fileContent( - expectedFile, + filePath, new RegExp(/@del\('/.source + restUrl + /\/{id}', {/.source), ); } diff --git a/packages/cli/test/integration/generators/datasource.integration.js b/packages/cli/test/integration/generators/datasource.integration.js index d740536c0ff9..9e2e858fd0f9 100644 --- a/packages/cli/test/integration/generators/datasource.integration.js +++ b/packages/cli/test/integration/generators/datasource.integration.js @@ -17,6 +17,7 @@ const generator = path.join(__dirname, '../../../generators/datasource'); const tests = require('../lib/artifact-generator')(generator); const baseTests = require('../lib/base-generator')(generator); const testUtils = require('../../test-utils'); +const {expectFileToMatchSnapshot} = require('../../snapshots'); // Test Sandbox const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); @@ -105,6 +106,7 @@ describe('lb4 datasource integration', () => { .withPrompts(basicCLIInput); checkBasicDataSourceFiles(); + assert.jsonFileContent(expectedJSONFile, basicCLIInput); }); @@ -159,47 +161,7 @@ function checkBasicDataSourceFiles() { assert.file(expectedIndexFile); assert.noFile(path.join(SANDBOX_PATH, 'node_modules/memory')); - /* - import { - inject, - lifeCycleObserver, - LifeCycleObserver, - ValueOrPromise, - } from '@loopback/core'; - */ - assert.fileContent( - expectedTSFile, - `import { - inject, - lifeCycleObserver, - LifeCycleObserver, - ValueOrPromise, -} from '@loopback/core';`, - ); - - assert.fileContent( - expectedTSFile, - /import {juggler} from '@loopback\/repository';/, - ); - assert.fileContent( - expectedTSFile, - /import \* as config from '.\/ds.datasource.json';/, - ); - assert.fileContent(expectedTSFile, /@lifeCycleObserver\('datasource'\)/); - assert.fileContent( - expectedTSFile, - /export class DsDataSource extends juggler.DataSource/, - ); - assert.fileContent(expectedTSFile, /implements LifeCycleObserver {/); - assert.fileContent(expectedTSFile, /static dataSourceName = 'ds';/); - assert.fileContent(expectedTSFile, /constructor\(/); - assert.fileContent( - expectedTSFile, - /\@inject\('datasources.config.ds', \{optional: true\}\)/, - ); - assert.fileContent(expectedTSFile, /\) \{/); - assert.fileContent(expectedTSFile, /super\(dsConfig\);/); - assert.fileContent(expectedTSFile, /stop\(\)\: ValueOrPromise/); + expectFileToMatchSnapshot(expectedTSFile); assert.fileContent(expectedIndexFile, /export \* from '.\/ds.datasource';/); } diff --git a/packages/cli/test/snapshot-matcher.js b/packages/cli/test/snapshot-matcher.js new file mode 100644 index 000000000000..cfa4d567b700 --- /dev/null +++ b/packages/cli/test/snapshot-matcher.js @@ -0,0 +1,205 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +/* +A lightweight helper for snapshot-based testing. +- Feature inspired by https://jestjs.io/docs/en/snapshot-testing +- Implementation inspired by node-tap + +This is an initial experimental version. In the (near) future, we should +move this file to a standalone package so that all Mocha users can use it. +*/ + +const assert = require('assert'); +const path = require('path'); + +module.exports = { + initializeSnapshots, +}; + +/** + * Create a function to match the given value against a pre-recorder snapshot. + * + * Example usage: + * + * ```js + * // At the top of your test file, initialize the matcher function. + * // You can also move this code to a shared file require()d from tests. + * const expectToMatchSnapshot = initializeSnapshots( + * path.resolve(__dirname, '../../__snapshots__'), + * ); + * + * // Inside your tests, call `expectToMatchSnapshot(actualValue)` + * describe('my feature', () => { + * it('works for strings', function() { + * expectToMatchSnapshot('foo\nbar'); + * }); + * }); + * ``` + */ +function initializeSnapshots(snapshotDir) { + let currentTest; + + beforeEach(function setupSnapshots() { + // eslint-disable-next-line no-invalid-this + currentTest = this.currentTest; + currentTest.__snapshotCounter = 1; + }); + + if (!process.env.UPDATE_SNAPSHOTS) { + return function expectToMatchSnapshot(actual) { + matchSnapshot(snapshotDir, currentTest, actual); + }; + } + + const snapshots = Object.create(null); + after(function updateSnapshots() { + for (const f in snapshots) { + const snapshotFile = buildSnapshotFilePath(snapshotDir, f); + writeSnapshotData(snapshotFile, snapshots[f]); + } + }); + + return function expectToRecordSnapshot(actual) { + recordSnapshot(snapshots, currentTest, actual); + }; +} + +function matchSnapshot(snapshotDir, currentTest, actualValue) { + assert( + typeof actualValue === 'string', + 'Snapshot matcher supports string values only, but was called with ' + + typeof actualValue, + ); + + const snapshotFile = buildSnapshotFilePath(snapshotDir, currentTest.file); + const snapshotData = loadSnapshotData(snapshotFile); + const key = buildSnapshotKey(currentTest); + + if (!(key in snapshotData)) { + throw new Error( + `No snapshot found for ${JSON.stringify(key)}.\n` + + 'Run the tests with UPDATE_SNAPSHOTS=1 in the environment ' + + 'to create and update snapshot files.', + ); + } + + assert.deepStrictEqual(actualValue, snapshotData[key]); +} + +function recordSnapshot(snapshots, currentTest, actualValue) { + assert( + typeof actualValue === 'string', + 'Snapshot matcher supports string values only, but was called with ' + + typeof actualValue, + ); + + const key = buildSnapshotKey(currentTest); + const testFile = currentTest.file; + if (!snapshots[testFile]) snapshots[testFile] = Object.create(null); + snapshots[testFile][key] = actualValue; +} + +function buildSnapshotKey(currentTest) { + const counter = currentTest.__snapshotCounter || 1; + currentTest.__snapshotCounter = counter + 1; + return `${getFullTestName(currentTest)} ${counter}`; +} + +function getFullTestName(currentTest) { + let result = currentTest.title; + for (;;) { + if (!currentTest.parent) break; + currentTest = currentTest.parent; + if (currentTest.title) { + result = currentTest.title + ' ' + result; + } + } + return result; +} + +function buildSnapshotFilePath(snapshotDir, currentTestFile) { + const parsed = path.parse(currentTestFile); + const parts = path.normalize(parsed.dir).split(path.sep); + + const ix = parts.findIndex(p => p === 'test' || p === '__tests__'); + if (ix < 0) { + throw new Error( + 'Snapshot checker requires test files in `test` or `__tests__`', + ); + } + + // Remove everything from start up to (including) `test` or `__tests__` + parts.splice(0, ix + 1); + + return path.join(snapshotDir, ...parts, parsed.name + '.snapshots.js'); +} + +function loadSnapshotData(snapshotFile) { + try { + const data = require(snapshotFile); + for (const key in data) { + const entry = data[key]; + if (entry.length > 2 && entry.startsWith('\n') && entry.endsWith('\n')) { + data[key] = entry.slice(1, -1); + } + } + return data; + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + throw new Error( + `Snapshot file ${snapshotFile} was not found.\n` + + 'Run the tests with UPDATE_SNAPSHOTS=1 in the environment ' + + 'to create and update snapshot files.', + ); + } + throw err; + } +} + +function writeSnapshotData(snapshotFile, snapshots) { + const writeFileAtomic = require('write-file-atomic'); + const naturalCompare = require('natural-compare'); + const mkdirp = require('mkdirp'); + + const header = `// IMPORTANT +// This snapshot file is auto-generated, but designed for humans. +// It should be checked into source control and tracked carefully. +// Re-generate by setting UPDATE_SNAPSHOTS=1 and running tests. +// Make sure to inspect the changes in the snapshots below. +// Do not ignore changes! + +'use strict'; +`; + + const entries = Object.keys(snapshots) + .sort(naturalCompare) + .map(key => buildSnapshotCode(key, snapshots[key])); + + const content = header + entries.join('\n'); + mkdirp.sync(path.dirname(snapshotFile)); + writeFileAtomic.sync(snapshotFile, content, {encoding: 'utf-8'}); +} + +function buildSnapshotCode(key, value) { + return ` +exports[\`${escape(key)}\`] = \` +${escape(normalizeNewlines(value))} +\`; +`; +} + +function escape(value) { + return value + .replace(/\\/g, '\\\\') + .replace(/\`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +function normalizeNewlines(value) { + return value.replace(/\r\n|\r/g, '\n'); +} diff --git a/packages/cli/test/snapshots.js b/packages/cli/test/snapshots.js new file mode 100644 index 000000000000..74d209a727b9 --- /dev/null +++ b/packages/cli/test/snapshots.js @@ -0,0 +1,26 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const assert = require('yeoman-assert'); +const fs = require('fs'); +const path = require('path'); +const {initializeSnapshots} = require('./snapshot-matcher'); + +const expectToMatchSnapshot = initializeSnapshots( + path.resolve(__dirname, '../snapshots'), +); + +function expectFileToMatchSnapshot(filePath) { + assert.file(filePath); + const content = fs.readFileSync(filePath, {encoding: 'utf-8'}); + expectToMatchSnapshot(content); +} + +module.exports = { + expectToMatchSnapshot, + expectFileToMatchSnapshot, +};