diff --git a/docs/angular/guides/nx-devkit-angular-devkit.md b/docs/angular/guides/nx-devkit-angular-devkit.md index e7859c9319..df59b7394b 100644 --- a/docs/angular/guides/nx-devkit-angular-devkit.md +++ b/docs/angular/guides/nx-devkit-angular-devkit.md @@ -90,7 +90,7 @@ interface Schema { export default async function ( options: Schema, - context: TargetContext + context: ExecutorContext ): Promise<{ success: true }> { if (options.allCaps) { console.log(options.message.toUpperCase()); diff --git a/docs/shared/devkit.md b/docs/shared/devkit.md index d15f9f0432..9ef422b815 100644 --- a/docs/shared/devkit.md +++ b/docs/shared/devkit.md @@ -185,7 +185,7 @@ interface Schema { export default async function ( options: Schema, - context: TargetContext + context: ExecutorContext ): Promise<{ success: true }> { if (options.allCaps) { console.log(options.message.toUpperCase()); diff --git a/packages/cypress/builders.json b/packages/cypress/builders.json deleted file mode 100644 index 4bc0331eaf..0000000000 --- a/packages/cypress/builders.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "@angular-devkit/architect/src/builders-schema.json", - "builders": { - "cypress": { - "implementation": "./src/builders/cypress/cypress.impl", - "schema": "./src/builders/cypress/schema.json", - "description": "Run Cypress e2e tests" - } - } -} diff --git a/packages/cypress/executors.json b/packages/cypress/executors.json new file mode 100644 index 0000000000..e15fbb8ffd --- /dev/null +++ b/packages/cypress/executors.json @@ -0,0 +1,16 @@ +{ + "builders": { + "cypress": { + "implementation": "./src/executors/cypress/compat", + "schema": "./src/executors/cypress/schema.json", + "description": "Run Cypress e2e tests" + } + }, + "executors": { + "cypress": { + "implementation": "./src/executors/cypress/cypress.impl", + "schema": "./src/executors/cypress/schema.json", + "description": "Run Cypress e2e tests" + } + } +} diff --git a/packages/cypress/package.json b/packages/cypress/package.json index ad170d05da..4d13c36138 100644 --- a/packages/cypress/package.json +++ b/packages/cypress/package.json @@ -26,7 +26,7 @@ }, "homepage": "https://nx.dev", "schematics": "./collection.json", - "builders": "./builders.json", + "builders": "./executors.json", "ng-update": { "requirements": {}, "migrations": "./migrations.json" diff --git a/packages/cypress/src/builders/cypress/cypress.impl.spec.ts b/packages/cypress/src/builders/cypress/cypress.impl.spec.ts deleted file mode 100644 index 597fd9959e..0000000000 --- a/packages/cypress/src/builders/cypress/cypress.impl.spec.ts +++ /dev/null @@ -1,488 +0,0 @@ -jest.mock('@angular-devkit/architect'); -let devkitArchitect = require('@angular-devkit/architect'); -import { Architect } from '@angular-devkit/architect'; -import { TestingArchitectHost } from '@angular-devkit/architect/testing'; -import { schema } from '@angular-devkit/core'; -jest.mock('@nrwl/workspace'); -let fsUtility = require('@nrwl/workspace'); -import { MockBuilderContext } from '@nrwl/workspace/testing'; -import * as child_process from 'child_process'; -import { EventEmitter } from 'events'; -import * as fsExtras from 'fs-extra'; -import * as path from 'path'; -import { of } from 'rxjs'; -import { CypressBuilderOptions, cypressBuilderRunner } from './cypress.impl'; - -jest.mock('../../utils/cypress-version'); -import { installedCypressVersion } from '../../utils/cypress-version'; - -const Cypress = require('cypress'); - -describe('Cypress builder', () => { - let architect: Architect; - let cypressRun: jasmine.Spy; - let cypressOpen: jasmine.Spy; - let fakeEventEmitter: EventEmitter; - let fork: jasmine.Spy; - let cypressConfig: any; - let mockedBuilderContext: MockBuilderContext; - let mockedInstalledCypressVersion: jest.Mock< - ReturnType - > = installedCypressVersion as any; - const cypressBuilderOptions: CypressBuilderOptions = { - cypressConfig: 'apps/my-app-e2e/cypress.json', - parallel: false, - tsConfig: 'apps/my-app-e2e/tsconfig.json', - devServerTarget: 'my-app:serve', - headless: true, - exit: true, - record: false, - baseUrl: undefined, - watch: false, - }; - - beforeEach(async () => { - const registry = new schema.CoreSchemaRegistry(); - registry.addPostTransform(schema.transforms.addUndefinedDefaults); - const testArchitectHost = new TestingArchitectHost('/root', '/root'); - - architect = new Architect(testArchitectHost, registry); - await testArchitectHost.addBuilderFromPackage( - path.join(__dirname, '../../..') - ); - - mockedBuilderContext = new MockBuilderContext(architect, testArchitectHost); - - (devkitArchitect as any).scheduleTargetAndForget = jest - .fn() - .mockReturnValue( - of({ - success: true, - baseUrl: 'http://localhost:4200', - }) - ); - fakeEventEmitter = new EventEmitter(); - fork = spyOn(child_process, 'fork').and.returnValue(fakeEventEmitter); - cypressRun = spyOn(Cypress, 'run').and.returnValue(Promise.resolve({})); - cypressOpen = spyOn(Cypress, 'open').and.returnValue(Promise.resolve({})); - cypressConfig = { - fixturesFolder: './src/fixtures', - integrationFolder: './src/integration', - }; - spyOn(fsUtility, 'readJsonFile').and.callFake((path) => { - return path.endsWith('tsconfig.json') - ? { - compilerOptions: { - outDir: '../../dist/out-tsc/apps/my-app-e2e/src', - }, - } - : cypressConfig; - }); - spyOn(fsExtras, 'copySync'); - spyOn(process, 'exit'); - }); - - it('should call `Cypress.run` if headless mode is `true`', async (done) => { - cypressBuilderRunner(cypressBuilderOptions, mockedBuilderContext) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - config: { baseUrl: 'http://localhost:4200' }, - project: path.dirname(cypressBuilderOptions.cypressConfig), - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.open` if headless mode is `false`', async (done) => { - cypressBuilderRunner( - { - ...cypressBuilderOptions, - headless: false, - watch: true, - }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect(cypressOpen).toHaveBeenCalledWith( - jasmine.objectContaining({ - config: { baseUrl: 'http://localhost:4200' }, - project: path.dirname(cypressBuilderOptions.cypressConfig), - }) - ); - expect(cypressRun).not.toHaveBeenCalled(); - done(); - }); - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.run` with provided baseUrl', async (done) => { - cypressBuilderRunner( - { - ...cypressBuilderOptions, - devServerTarget: undefined, - baseUrl: 'http://my-distant-host.com', - }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - config: { - baseUrl: 'http://my-distant-host.com', - }, - project: path.dirname(cypressBuilderOptions.cypressConfig), - }) - ); - done(); - expect(cypressOpen).not.toHaveBeenCalled(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.run` with provided browser', async (done) => { - cypressBuilderRunner( - { - ...cypressBuilderOptions, - browser: 'chrome', - }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - browser: 'chrome', - project: path.dirname(cypressBuilderOptions.cypressConfig), - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.run` without baseUrl nor dev server target value', async (done) => { - cypressBuilderRunner( - { - cypressConfig: 'apps/my-app-e2e/cypress.json', - tsConfig: 'apps/my-app-e2e/tsconfig.json', - devServerTarget: undefined, - headless: true, - exit: true, - parallel: false, - record: false, - baseUrl: undefined, - watch: false, - }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - project: path.dirname(cypressBuilderOptions.cypressConfig), - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.run` with a string of files to ignore', async (done) => { - const cfg = { - ...cypressBuilderOptions, - ignoreTestFiles: '/some/path/to/a/file.js', - }; - - cypressBuilderRunner(cfg, mockedBuilderContext) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - ignoreTestFiles: cfg.ignoreTestFiles, - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should call `Cypress.run` with a reporter and reporterOptions', async (done) => { - const cfg = { - ...cypressBuilderOptions, - reporter: 'junit', - reporterOptions: 'mochaFile=reports/results-[hash].xml,toConsole=true', - }; - - cypressBuilderRunner(cfg, mockedBuilderContext) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - reporter: cfg.reporter, - reporterOptions: cfg.reporterOptions, - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should fail early if application build fails', async (done) => { - (devkitArchitect as any).scheduleTargetAndForget = jest - .fn() - .mockReturnValue( - of({ - success: false, - }) - ); - cypressBuilderRunner(cypressBuilderOptions, mockedBuilderContext) - .toPromise() - .then((res) => { - expect(res.success).toBe(false); - done(); - }); - }); - - it('should call `Cypress.run` with provided cypressConfig as project and configFile', async (done) => { - const cfg = { - ...cypressBuilderOptions, - cypressConfig: 'some/project/my-cypress.json', - }; - - cypressBuilderRunner(cfg, mockedBuilderContext) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - project: path.dirname(cfg.cypressConfig), - configFile: path.basename(cfg.cypressConfig), - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should show warnings if using unsupported browsers v3', async (done) => { - mockedInstalledCypressVersion.mockReturnValue(3); - const result = await cypressBuilderRunner( - { ...cypressBuilderOptions, browser: 'edge' }, - mockedBuilderContext - ).toPromise(); - - expect( - mockedBuilderContext.logger.includes( - 'You are using a browser that is not supported by cypress v3.' - ) - ).toBeTruthy(); - done(); - }); - - it('should show warnings if using unsupported browsers v4', async (done) => { - mockedInstalledCypressVersion.mockReturnValue(4); - - const result = await cypressBuilderRunner( - { ...cypressBuilderOptions, browser: 'canary' }, - mockedBuilderContext - ).toPromise(); - - expect( - mockedBuilderContext.logger.includes( - 'You are using a browser that is not supported by cypress v4+.' - ) - ).toBeTruthy(); - done(); - }); - - it('should call `Cypress.run` with provided environment variables through additional properties', async (done) => { - cypressBuilderRunner( - { - ...cypressBuilderOptions, - '--': ['--env.x=x', '--env.y', 'y'], - }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect(cypressRun).toHaveBeenCalledWith( - jasmine.objectContaining({ - env: jasmine.objectContaining({ x: 'x', y: 'y' }), - }) - ); - expect(cypressOpen).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - test('when devServerTarget AND baseUrl options are both present, baseUrl should take precidence', async (done) => { - const options: CypressBuilderOptions = { - ...cypressBuilderOptions, - baseUrl: 'test-url-from-options', - }; - const result = await cypressBuilderRunner( - options, - mockedBuilderContext - ).toPromise(); - expect(cypressRun.calls.mostRecent().args[0].config.baseUrl).toBe( - 'test-url-from-options' - ); - done(); - }); - - test('when devServerTarget option present and baseUrl option is absent, baseUrl should come from devServerTarget', async (done) => { - await cypressBuilderRunner( - cypressBuilderOptions, - mockedBuilderContext - ).toPromise(); - expect(cypressRun.calls.mostRecent().args[0].config.baseUrl).toBe( - 'http://localhost:4200' - ); - done(); - }); - - describe('legacy', () => { - beforeEach(() => { - cypressConfig = { - fixturesFolder: '../../dist/out-tsc/apps/my-app-e2e/src/fixtures', - integrationFolder: '../../dist/out-tsc/apps/my-app-e2e/src/integration', - }; - }); - - it('should call `fork.child_process` with the tsc command', async () => { - cypressBuilderRunner( - cypressBuilderOptions, - mockedBuilderContext - ).subscribe(); - expect(fork).toHaveBeenCalledWith( - '/root/node_modules/typescript/bin/tsc', - ['-p', '/root/apps/my-app-e2e/tsconfig.json'], - { stdio: [0, 1, 2, 'ipc'] } - ); - }); - - it('should copy fixtures folder to out-dir', async (done) => { - cypressBuilderRunner(cypressBuilderOptions, mockedBuilderContext) - .toPromise() - .then(() => { - expect( - fsExtras.copySync - ).toHaveBeenCalledWith( - '/root/apps/my-app-e2e/src/fixtures', - '/root/dist/out-tsc/apps/my-app-e2e/src/fixtures', - { overwrite: true } - ); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should not copy fixtures folder if they are not defined in the cypress config', async (done) => { - delete cypressConfig.fixturesFolder; - cypressBuilderRunner(cypressBuilderOptions, mockedBuilderContext) - .toPromise() - .then(() => { - expect(fsExtras.copySync).not.toHaveBeenCalled(); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should copy regex files to out-dir', async (done) => { - const regex: string = '^.+\\.feature$'; - - cypressBuilderRunner( - { ...cypressBuilderOptions, copyFiles: regex }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect( - fsExtras.copySync - ).toHaveBeenCalledWith( - '/root/apps/my-app-e2e/src/integration', - '/root/dist/out-tsc/apps/my-app-e2e/src/integration', - { filter: jasmine.any(Function) } - ); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should not copy regex files if the regex is not defined', async (done) => { - const regex: string = undefined; - - cypressBuilderRunner( - { ...cypressBuilderOptions, copyFiles: regex }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - expect( - fsExtras.copySync - ).not.toHaveBeenCalledWith( - '/root/apps/my-app-e2e/src/integration', - '/root/dist/out-tsc/apps/my-app-e2e/src/integration', - { filter: jasmine.any(Function) } - ); - done(); - }); - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should not copy regex files if the integration files are not defined in the cypress config', async (done) => { - delete cypressConfig.integrationFolder; - - const regex: string = '^.+\\.feature$'; - - try { - cypressBuilderRunner( - { ...cypressBuilderOptions, copyFiles: regex }, - mockedBuilderContext - ) - .toPromise() - .then(() => { - fail(); - }); - } catch (e) { - done(); - } - - fakeEventEmitter.emit('exit', 0); // Passing tsc command - }); - - it('should fail early if integration files fail to compile', async (done) => { - cypressBuilderRunner(cypressBuilderOptions, mockedBuilderContext) - .toPromise() - .then((res) => { - expect(res.success).toBe(false); - done(); - }); - - fakeEventEmitter.emit('exit', 1); // Passing tsc command - }); - }); -}); diff --git a/packages/cypress/src/builders/cypress/cypress.impl.ts b/packages/cypress/src/builders/cypress/cypress.impl.ts deleted file mode 100644 index 16ca923a28..0000000000 --- a/packages/cypress/src/builders/cypress/cypress.impl.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { - BuilderContext, - createBuilder, - BuilderOutput, - scheduleTargetAndForget, - targetFromTargetString, -} from '@angular-devkit/architect'; -import { Observable, of, noop } from 'rxjs'; -import { catchError, concatMap, tap, map, take } from 'rxjs/operators'; -import { fromPromise } from 'rxjs/internal-compatibility'; -import { JsonObject } from '@angular-devkit/core'; -import { dirname, join, relative, basename } from 'path'; -import { readJsonFile } from '@nrwl/workspace'; -import { legacyCompile } from './legacy'; -import { stripIndents } from '@angular-devkit/core/src/utils/literals'; -import { installedCypressVersion } from '../../utils/cypress-version'; -import * as yargsParser from 'yargs-parser'; - -const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies. - -export interface CypressBuilderOptions extends JsonObject { - baseUrl: string; - cypressConfig: string; - devServerTarget: string; - headless: boolean; - exit: boolean; - parallel: boolean; - record: boolean; - key?: string; - tsConfig: string; - watch: boolean; - browser?: string; - env?: Record; - spec?: string; - copyFiles?: string; - ciBuildId?: string; - group?: string; - ignoreTestFiles?: string; - reporter?: string; - reporterOptions?: string; -} - -try { - require('dotenv').config(); -} catch (e) {} - -export default createBuilder(cypressBuilderRunner); - -/** - * @whatItDoes This is the starting point of the builder. - * @param options - * @param context - */ -export function cypressBuilderRunner( - options: CypressBuilderOptions, - context: BuilderContext -): Observable { - // Special handling of extra options coming through Angular CLI - if (options['--']) { - const { _, ...overrides } = yargsParser(options['--'] as string[], { - configuration: { 'camel-case-expansion': false }, - }); - options = { ...options, ...overrides }; - } - - const legacy = isLegacy(options, context); - if (legacy) { - showLegacyWarning(context); - } - options.env = options.env || {}; - if (options.tsConfig) { - options.env.tsConfig = join(context.workspaceRoot, options.tsConfig); - } - - checkSupportedBrowser(options, context); - - return (!legacy - ? options.devServerTarget - ? startDevServer(options.devServerTarget, options.watch, context).pipe( - map((devServerBaseUrl) => options.baseUrl || devServerBaseUrl) - ) - : of(options.baseUrl) - : legacyCompile(options, context) - ).pipe( - concatMap((baseUrl: string) => - initCypress( - options.cypressConfig, - options.headless, - options.exit, - options.record, - options.key, - options.parallel, - options.watch, - baseUrl, - options.browser, - options.env, - options.spec, - options.ciBuildId, - options.group, - options.ignoreTestFiles, - options.reporter, - options.reporterOptions - ) - ), - options.watch ? tap(noop) : take(1), - catchError((error) => { - context.reportStatus(`Error: ${error.message}`); - context.logger.error(error.message); - return of({ - success: false, - }); - }) - ); -} - -/** - * @whatItDoes Initialize the Cypress test runner with the provided project configuration. - * If `headless` is `false`: open the Cypress application, the user will - * be able to interact directly with the application. - * If `headless` is `true`: Cypress will run in headless mode and will - * provide directly the results in the console output. - * @param cypressConfig - * @param headless - * @param exit - * @param record - * @param key - * @param parallel - * @param baseUrl - * @param isWatching - * @param browser - * @param env - * @param spec - * @param ciBuildId - * @param group - * @param ignoreTestFiles - */ -function initCypress( - cypressConfig: string, - headless: boolean, - exit: boolean, - record: boolean, - key: string, - parallel: boolean, - isWatching: boolean, - baseUrl: string, - browser?: string, - env?: Record, - spec?: string, - ciBuildId?: string, - group?: string, - ignoreTestFiles?: string, - reporter?: string, - reporterOptions?: string -): Observable { - // Cypress expects the folder where a `cypress.json` is present - const projectFolderPath = dirname(cypressConfig); - const options: any = { - project: projectFolderPath, - configFile: basename(cypressConfig), - }; - - // If not, will use the `baseUrl` normally from `cypress.json` - if (baseUrl) { - options.config = { baseUrl: baseUrl }; - } - - if (browser) { - options.browser = browser; - } - - if (env) { - options.env = env; - } - if (spec) { - options.spec = spec; - } - - options.exit = exit; - options.headed = !headless; - options.headless = headless; - options.record = record; - options.key = key; - options.parallel = parallel; - options.ciBuildId = ciBuildId; - options.group = group; - options.ignoreTestFiles = ignoreTestFiles; - options.reporter = reporter; - options.reporterOptions = reporterOptions; - - return fromPromise( - !isWatching || headless ? Cypress.run(options) : Cypress.open(options) - ).pipe( - // tap(() => (isWatching && !headless ? process.exit() : null)), // Forcing `cypress.open` to give back the terminal - map((result) => ({ - /** - * `cypress.open` is returning `0` and is not of the same type as `cypress.run`. - * `cypress.open` is the graphical UI, so it will be obvious to know what wasn't - * working. Forcing the build to success when `cypress.open` is used. - */ - success: !result.totalFailed && !result.failures, - })) - ); -} - -/** - * @whatItDoes Compile the application using the webpack builder. - * @param devServerTarget - * @param isWatching - * @param context - * @private - */ -export function startDevServer( - devServerTarget: string, - isWatching: boolean, - context: BuilderContext -): Observable { - // Overrides dev server watch setting. - const overrides = { - watch: isWatching, - }; - return scheduleTargetAndForget( - context, - targetFromTargetString(devServerTarget), - overrides - ).pipe( - map((output) => { - if (!output.success && !isWatching) { - throw new Error('Could not compile application files'); - } - return output.baseUrl as string; - }) - ); -} - -function isLegacy( - options: CypressBuilderOptions, - context: BuilderContext -): boolean { - const tsconfigJson = readJsonFile( - join(context.workspaceRoot, options.tsConfig) - ); - const cypressConfigPath = join(context.workspaceRoot, options.cypressConfig); - const cypressJson = readJsonFile(cypressConfigPath); - - if (!cypressJson.integrationFolder) { - throw new Error( - `"integrationFolder" is not defined in ${options.cypressConfig}` - ); - } - - const integrationFolder = join( - dirname(cypressConfigPath), - cypressJson.integrationFolder - ); - const tsOutDirPath = join( - context.workspaceRoot, - dirname(options.tsConfig), - tsconfigJson.compilerOptions.outDir - ); - - return !relative(tsOutDirPath, integrationFolder).startsWith('..'); -} - -function showLegacyWarning(context: BuilderContext) { - context.logger.warn(stripIndents` - Warning: - You are using the legacy configuration for cypress. - Please run "ng update @nrwl/cypress --from 8.1.0 --to 8.2.0 --migrate-only".`); -} - -function checkSupportedBrowser( - { browser }: CypressBuilderOptions, - context: BuilderContext -) { - // Browser was not passed in as an option, cypress will use whatever default it has set and we dont need to check it - if (!browser) { - return; - } - - if (installedCypressVersion() >= 4 && browser == 'canary') { - context.logger.warn(stripIndents` - Warning: - You are using a browser that is not supported by cypress v4+. - - Read here for more info: - https://docs.cypress.io/guides/references/migration-guide.html#Launching-Chrome-Canary-with-browser - `); - return; - } - - const supportedV3Browsers = ['electron', 'chrome', 'canary', 'chromium']; - if ( - installedCypressVersion() <= 3 && - !supportedV3Browsers.includes(browser) - ) { - context.logger.warn(stripIndents` - Warning: - You are using a browser that is not supported by cypress v3. - `); - return; - } -} diff --git a/packages/cypress/src/builders/cypress/legacy.ts b/packages/cypress/src/builders/cypress/legacy.ts deleted file mode 100644 index e75876211f..0000000000 --- a/packages/cypress/src/builders/cypress/legacy.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { ChildProcess, fork } from 'child_process'; -import { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import { readJsonFile } from '@nrwl/workspace'; -import { dirname, join } from 'path'; -import { removeSync, copySync } from 'fs-extra'; -import { concatMap, map, tap } from 'rxjs/operators'; -import { Observable, of, Subscriber } from 'rxjs'; -import * as treeKill from 'tree-kill'; -import { CypressBuilderOptions, startDevServer } from './cypress.impl'; - -let tscProcess: ChildProcess; - -export function legacyCompile( - options: CypressBuilderOptions, - context: BuilderContext -) { - const tsconfigJson = readJsonFile( - join(context.workspaceRoot, options.tsConfig) - ); - - // Cleaning the /dist folder - removeSync( - join(dirname(options.tsConfig), tsconfigJson.compilerOptions.outDir) - ); - - return compileTypescriptFiles(options.tsConfig, options.watch, context).pipe( - tap(() => { - copyCypressFixtures(options.cypressConfig, context); - copyIntegrationFilesByRegex( - options.cypressConfig, - context, - options.copyFiles - ); - }), - concatMap(() => - options.devServerTarget - ? startDevServer(options.devServerTarget, options.watch, context) - : of(options.baseUrl) - ) - ); -} - -/** - * @whatItDoes Compile typescript spec files to be able to run Cypress. - * The compilation is done via executing the `tsc` command line/ - * @param tsConfigPath - * @param isWatching - */ -function compileTypescriptFiles( - tsConfigPath: string, - isWatching: boolean, - context: BuilderContext -): Observable { - if (tscProcess) { - killProcess(context); - } - return Observable.create((subscriber: Subscriber) => { - try { - let args = ['-p', join(context.workspaceRoot, tsConfigPath)]; - const tscPath = join( - context.workspaceRoot, - '/node_modules/typescript/bin/tsc' - ); - if (isWatching) { - args.push('--watch'); - tscProcess = fork(tscPath, args, { stdio: [0, 1, 2, 'ipc'] }); - subscriber.next({ success: true }); - } else { - tscProcess = fork(tscPath, args, { stdio: [0, 1, 2, 'ipc'] }); - tscProcess.on('exit', (code) => { - code === 0 - ? subscriber.next({ success: true }) - : subscriber.error('Could not compile Typescript files'); - subscriber.complete(); - }); - } - } catch (error) { - if (tscProcess) { - killProcess(context); - } - subscriber.error( - new Error(`Could not compile Typescript files: \n ${error}`) - ); - } - }); -} -function copyCypressFixtures( - cypressConfigPath: string, - context: BuilderContext -) { - const cypressConfig = readJsonFile( - join(context.workspaceRoot, cypressConfigPath) - ); - // DOn't copy fixtures if cypress config does not have it set - if (!cypressConfig.fixturesFolder) { - return; - } - - copySync( - `${dirname(join(context.workspaceRoot, cypressConfigPath))}/src/fixtures`, - join( - dirname(join(context.workspaceRoot, cypressConfigPath)), - cypressConfig.fixturesFolder - ), - { overwrite: true } - ); -} - -/** - * @whatItDoes Copy all the integration files that match the given regex into the dist folder. - * This is done because `tsc` doesn't handle all file types, e.g. Cucumbers `feature` files. - * @param fileExtension File extension to copy - */ -function copyIntegrationFilesByRegex( - cypressConfigPath: string, - context: BuilderContext, - regex: string -) { - const cypressConfig = readJsonFile( - join(context.workspaceRoot, cypressConfigPath) - ); - - if (!regex || !cypressConfig.integrationFolder) { - return; - } - - const regExp: RegExp = new RegExp(regex); - - copySync( - `${dirname( - join(context.workspaceRoot, cypressConfigPath) - )}/src/integration`, - join( - dirname(join(context.workspaceRoot, cypressConfigPath)), - cypressConfig.integrationFolder - ), - { filter: (file) => regExp.test(file) } - ); -} - -function killProcess(context: BuilderContext): void { - return treeKill(tscProcess.pid, 'SIGTERM', (error) => { - tscProcess = null; - if (error) { - if (Array.isArray(error) && error[0] && error[2]) { - const errorMessage = error[2]; - context.logger.error(errorMessage); - } else if (error.message) { - context.logger.error(error.message); - } - } - }); -} diff --git a/packages/cypress/src/executors/cypress/compat.ts b/packages/cypress/src/executors/cypress/compat.ts new file mode 100644 index 0000000000..67ea703f81 --- /dev/null +++ b/packages/cypress/src/executors/cypress/compat.ts @@ -0,0 +1,5 @@ +import { convertNxExecutor } from '@nrwl/devkit'; + +import { default as cypressExecutor } from './cypress.impl'; + +export default convertNxExecutor(cypressExecutor); diff --git a/packages/cypress/src/executors/cypress/cypress.impl.spec.ts b/packages/cypress/src/executors/cypress/cypress.impl.spec.ts new file mode 100644 index 0000000000..3b227830d2 --- /dev/null +++ b/packages/cypress/src/executors/cypress/cypress.impl.spec.ts @@ -0,0 +1,256 @@ +import * as path from 'path'; +import cypressExecutor, { CypressExecutorOptions } from './cypress.impl'; + +jest.mock('@nrwl/devkit'); +let devkit = require('@nrwl/devkit'); + +jest.mock('../../utils/cypress-version'); +import { installedCypressVersion } from '../../utils/cypress-version'; + +const Cypress = require('cypress'); + +describe('Cypress builder', () => { + let cypressRun: jasmine.Spy; + let cypressOpen: jasmine.Spy; + const cypressOptions: CypressExecutorOptions = { + cypressConfig: 'apps/my-app-e2e/cypress.json', + parallel: false, + tsConfig: 'apps/my-app-e2e/tsconfig.json', + devServerTarget: 'my-app:serve', + headless: true, + exit: true, + record: false, + baseUrl: undefined, + watch: false, + }; + let mockContext; + let mockedInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as any; + mockContext = { root: '/root', workspace: { projects: {} } } as any; + + beforeEach(async () => { + (devkit as any).runExecutor = jest.fn().mockReturnValue([ + { + success: true, + baseUrl: 'http://localhost:4200', + }, + ]); + (devkit as any).stripIndents = (s) => s; + cypressRun = spyOn(Cypress, 'run').and.returnValue(Promise.resolve({})); + cypressOpen = spyOn(Cypress, 'open').and.returnValue(Promise.resolve({})); + }); + + it('should call `Cypress.run` if headless mode is `true`', async (done) => { + const { success } = await cypressExecutor(cypressOptions, mockContext); + expect(success).toEqual(true); + + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + config: { baseUrl: 'http://localhost:4200' }, + project: path.dirname(cypressOptions.cypressConfig), + }) + ); + expect(cypressOpen).not.toHaveBeenCalled(); + done(); + }); + + it('should call `Cypress.open` if headless mode is `false`', async (done) => { + const { success } = await cypressExecutor( + { ...cypressOptions, headless: false, watch: true }, + mockContext + ); + expect(success).toEqual(true); + + expect(cypressOpen).toHaveBeenCalledWith( + jasmine.objectContaining({ + config: { baseUrl: 'http://localhost:4200' }, + project: path.dirname(cypressOptions.cypressConfig), + }) + ); + expect(cypressRun).not.toHaveBeenCalled(); + done(); + }); + + it('should fail early if application build fails', async (done) => { + (devkit as any).runExecutor = jest.fn().mockReturnValue([ + { + success: false, + }, + ]); + try { + await cypressExecutor(cypressOptions, mockContext); + fail('Should not execute'); + } catch (e) {} + done(); + }); + + it('should show warnings if using unsupported browsers v3', async (done) => { + mockedInstalledCypressVersion.mockReturnValue(3); + await cypressExecutor( + { + ...cypressOptions, + browser: 'edge', + }, + mockContext + ); + + expect(devkit.logger.warn).toHaveBeenCalled(); + done(); + }); + + it('should show warnings if using unsupported browsers v4', async (done) => { + mockedInstalledCypressVersion.mockReturnValue(4); + await cypressExecutor( + { + ...cypressOptions, + browser: 'canary', + }, + mockContext + ); + + expect(devkit.logger.warn).toHaveBeenCalled(); + done(); + }); + + it('should call `Cypress.run` with provided baseUrl', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + devServerTarget: undefined, + baseUrl: 'http://my-distant-host.com', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + config: { + baseUrl: 'http://my-distant-host.com', + }, + project: path.dirname(cypressOptions.cypressConfig), + }) + ); + done(); + }); + + it('should call `Cypress.run` with provided browser', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + browser: 'chrome', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + browser: 'chrome', + project: path.dirname(cypressOptions.cypressConfig), + }) + ); + done(); + }); + + it('should call `Cypress.run` without baseUrl nor dev server target value', async (done) => { + const { success } = await cypressExecutor( + { + cypressConfig: 'apps/my-app-e2e/cypress.json', + tsConfig: 'apps/my-app-e2e/tsconfig.json', + devServerTarget: undefined, + headless: true, + exit: true, + parallel: false, + record: false, + baseUrl: undefined, + watch: false, + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + project: path.dirname(cypressOptions.cypressConfig), + }) + ); + done(); + }); + + it('should call `Cypress.run` with a string of files to ignore', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + ignoreTestFiles: '/some/path/to/a/file.js', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + ignoreTestFiles: '/some/path/to/a/file.js', + }) + ); + done(); + }); + + it('should call `Cypress.run` with a reporter and reporterOptions', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + reporter: 'junit', + reporterOptions: 'mochaFile=reports/results-[hash].xml,toConsole=true', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + reporter: 'junit', + reporterOptions: 'mochaFile=reports/results-[hash].xml,toConsole=true', + }) + ); + done(); + }); + + it('should call `Cypress.run` with provided cypressConfig as project and configFile', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + cypressConfig: 'some/project/my-cypress.json', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun).toHaveBeenCalledWith( + jasmine.objectContaining({ + project: 'some/project', + configFile: 'my-cypress.json', + }) + ); + done(); + }); + + it('when devServerTarget AND baseUrl options are both present, baseUrl should take precedence', async (done) => { + const { success } = await cypressExecutor( + { + ...cypressOptions, + baseUrl: 'test-url-from-options', + }, + mockContext + ); + expect(success).toEqual(true); + expect(cypressRun.calls.mostRecent().args[0].config.baseUrl).toBe( + 'test-url-from-options' + ); + done(); + }); + + it('when devServerTarget option present and baseUrl option is absent, baseUrl should come from devServerTarget', async (done) => { + const { success } = await cypressExecutor(cypressOptions, mockContext); + expect(success).toEqual(true); + expect(cypressRun.calls.mostRecent().args[0].config.baseUrl).toBe( + 'http://localhost:4200' + ); + done(); + }); +}); diff --git a/packages/cypress/src/executors/cypress/cypress.impl.ts b/packages/cypress/src/executors/cypress/cypress.impl.ts new file mode 100644 index 0000000000..3705865c5e --- /dev/null +++ b/packages/cypress/src/executors/cypress/cypress.impl.ts @@ -0,0 +1,183 @@ +import { basename, dirname, join } from 'path'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { + ExecutorContext, + logger, + runExecutor, + stripIndents, +} from '@nrwl/devkit'; + +const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies. + +export type Json = { [k: string]: any }; + +export interface CypressExecutorOptions extends Json { + baseUrl: string; + cypressConfig: string; + devServerTarget: string; + headless: boolean; + exit: boolean; + parallel: boolean; + record: boolean; + key?: string; + tsConfig: string; + watch: boolean; + browser?: string; + env?: Record; + spec?: string; + copyFiles?: string; + ciBuildId?: string; + group?: string; + ignoreTestFiles?: string; + reporter?: string; + reporterOptions?: string; +} + +try { + require('dotenv').config(); +} catch (e) {} + +export default async function cypressExecutor( + options: CypressExecutorOptions, + context: ExecutorContext +) { + options = normalizeOptions(options, context); + + let success; + for await (const baseUrl of startDevServer(options, context)) { + try { + success = await runCypress(baseUrl, options); + if (!options.watch) break; + } catch (e) { + logger.error(e.message); + success = false; + if (!options.watch) break; + } + } + + return { success }; +} + +function normalizeOptions( + options: CypressExecutorOptions, + context: ExecutorContext +) { + options.env = options.env || {}; + if (options.tsConfig) { + options.env.tsConfig = join(context.root, options.tsConfig); + } + checkSupportedBrowser(options); + return options; +} + +function checkSupportedBrowser({ browser }: CypressExecutorOptions) { + // Browser was not passed in as an option, cypress will use whatever default it has set and we dont need to check it + if (!browser) { + return; + } + + if (installedCypressVersion() >= 4 && browser == 'canary') { + logger.warn(stripIndents` + Warning: + You are using a browser that is not supported by cypress v4+. + + Read here for more info: + https://docs.cypress.io/guides/references/migration-guide.html#Launching-Chrome-Canary-with-browser + `); + return; + } + + const supportedV3Browsers = ['electron', 'chrome', 'canary', 'chromium']; + if ( + installedCypressVersion() <= 3 && + !supportedV3Browsers.includes(browser) + ) { + logger.warn(stripIndents` + Warning: + You are using a browser that is not supported by cypress v3. + `); + return; + } +} + +async function* startDevServer( + opts: CypressExecutorOptions, + context: ExecutorContext +) { + // no dev server, return the provisioned base url + if (!opts.devServerTarget) { + yield opts.baseUrl; + return; + } + + const [project, target, configuration] = opts.devServerTarget.split(':'); + for await (const output of await runExecutor<{ + success: boolean; + baseUrl?: string; + }>( + { project, target, configuration }, + { + watch: opts.watch, + }, + context + )) { + if (!output.success && !opts.watch) + throw new Error('Could not compile application files'); + yield opts.baseUrl || (output.baseUrl as string); + } +} + +/** + * @whatItDoes Initialize the Cypress test runner with the provided project configuration. + * If `headless` is `false`: open the Cypress application, the user will + * be able to interact directly with the application. + * If `headless` is `true`: Cypress will run in headless mode and will + * provide directly the results in the console output. + */ +async function runCypress(baseUrl: string, opts: CypressExecutorOptions) { + // Cypress expects the folder where a `cypress.json` is present + const projectFolderPath = dirname(opts.cypressConfig); + const options: any = { + project: projectFolderPath, + configFile: basename(opts.cypressConfig), + }; + + // If not, will use the `baseUrl` normally from `cypress.json` + if (baseUrl) { + options.config = { baseUrl: baseUrl }; + } + + if (opts.browser) { + options.browser = opts.browser; + } + + if (opts.env) { + options.env = opts.env; + } + if (opts.spec) { + options.spec = opts.spec; + } + + options.exit = opts.exit; + options.headed = !opts.headless; + options.headless = opts.headless; + options.record = opts.record; + options.key = opts.key; + options.parallel = opts.parallel; + options.ciBuildId = opts.ciBuildId; + options.group = opts.group; + options.ignoreTestFiles = opts.ignoreTestFiles; + options.reporter = opts.reporter; + options.reporterOptions = opts.reporterOptions; + + const result = await (!opts.watch || opts.headless + ? Cypress.run(options) + : Cypress.open(options)); + + /** + * `cypress.open` is returning `0` and is not of the same type as `cypress.run`. + * `cypress.open` is the graphical UI, so it will be obvious to know what wasn't + * working. Forcing the build to success when `cypress.open` is used. + */ + return !result.totalFailed && !result.failures; +} diff --git a/packages/cypress/src/builders/cypress/schema.json b/packages/cypress/src/executors/cypress/schema.json similarity index 99% rename from packages/cypress/src/builders/cypress/schema.json rename to packages/cypress/src/executors/cypress/schema.json index d533d48f7d..47d4c91df4 100644 --- a/packages/cypress/src/builders/cypress/schema.json +++ b/packages/cypress/src/executors/cypress/schema.json @@ -3,6 +3,7 @@ "description": "Cypress target option for Build Facade", "type": "object", "outputCapture": "pipe", + "cli": "nx", "properties": { "cypressConfig": { "type": "string", diff --git a/packages/devkit/index.ts b/packages/devkit/index.ts index 61213d2457..af4a7302a5 100644 --- a/packages/devkit/index.ts +++ b/packages/devkit/index.ts @@ -14,7 +14,7 @@ export { } from '@nrwl/tao/src/shared/nx'; export { logger } from '@nrwl/tao/src/shared/logger'; export { getPackageManagerCommand } from '@nrwl/tao/src/shared/package-manager'; -export { TargetContext } from '@nrwl/tao/src/commands/run'; +export { runExecutor } from '@nrwl/tao/src/commands/run'; export { formatFiles } from './src/generators/format-files'; export { generateFiles } from './src/generators/generate-files'; @@ -43,3 +43,4 @@ export { export { offsetFromRoot } from './src/utils/offset-from-root'; export { convertNxGenerator } from './src/utils/invoke-nx-generator'; export { convertNxExecutor } from './src/utils/convert-nx-executor'; +export { stripIndents } from './src/utils/strip-indents'; diff --git a/packages/devkit/src/utils/convert-nx-executor.ts b/packages/devkit/src/utils/convert-nx-executor.ts index 7eea857064..9529053726 100644 --- a/packages/devkit/src/utils/convert-nx-executor.ts +++ b/packages/devkit/src/utils/convert-nx-executor.ts @@ -22,6 +22,8 @@ export function convertNxExecutor(executor: Executor) { root: builderContext.workspaceRoot, projectName: builderContext.target.project, workspace: workspaceConfig, + cwd: process.cwd(), + isVerbose: false, }; if ( builderContext.target && diff --git a/packages/devkit/src/utils/strip-indents.ts b/packages/devkit/src/utils/strip-indents.ts new file mode 100644 index 0000000000..c7911647d5 --- /dev/null +++ b/packages/devkit/src/utils/strip-indents.ts @@ -0,0 +1,20 @@ +/** + * Removes indents, which is useful for printing warning and messages. + * + * Example: + * + * ```typescript + * stripIndents` + * Options: + * - option1 + * - option2 + * ` + * ``` + */ +export function stripIndents(strings, ...values) { + return String.raw(strings, ...values) + .split('\n') + .map((line) => line.trim()) + .join('\n') + .trim(); +} diff --git a/packages/jest/builders.json b/packages/jest/executors.json similarity index 85% rename from packages/jest/builders.json rename to packages/jest/executors.json index 6d28cf37e3..32ee933857 100644 --- a/packages/jest/builders.json +++ b/packages/jest/executors.json @@ -1,5 +1,4 @@ { - "$schema": "@angular-devkit/architect/src/builders-schema.json", "builders": { "jest": { "implementation": "./src/executors/jest/compat", diff --git a/packages/jest/package.json b/packages/jest/package.json index 90708b7351..6e3b43ca56 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -26,7 +26,7 @@ }, "homepage": "https://nx.dev", "schematics": "./collection.json", - "builders": "./builders.json", + "builders": "./executors.json", "ng-update": { "requirements": {}, "migrations": "./migrations.json" diff --git a/packages/jest/src/executors/jest/jest.impl.spec.ts b/packages/jest/src/executors/jest/jest.impl.spec.ts index d484a9c22b..34b574ef53 100644 --- a/packages/jest/src/executors/jest/jest.impl.spec.ts +++ b/packages/jest/src/executors/jest/jest.impl.spec.ts @@ -41,6 +41,8 @@ describe('Jest Executor', () => { target: { executor: '@nrwl/jest:jest', }, + cwd: '/root', + isVerbose: true, }; }); diff --git a/packages/tao/src/commands/ngcli-adapter.ts b/packages/tao/src/commands/ngcli-adapter.ts index 7397316cac..6ff9dd7d51 100644 --- a/packages/tao/src/commands/ngcli-adapter.ts +++ b/packages/tao/src/commands/ngcli-adapter.ts @@ -49,7 +49,12 @@ import * as path from 'path'; export async function scheduleTarget( root: string, - opts: RunOptions, + opts: { + project: string; + target: string; + configuration: string; + runOptions: any; + }, verbose: boolean ): Promise> { const logger = getLogger(verbose); @@ -72,6 +77,7 @@ export async function scheduleTarget( opts.runOptions, { logger } ); + return run.output; } diff --git a/packages/tao/src/commands/run.ts b/packages/tao/src/commands/run.ts index 76c15ad098..748d2b1c6e 100644 --- a/packages/tao/src/commands/run.ts +++ b/packages/tao/src/commands/run.ts @@ -8,7 +8,7 @@ import { } from '../shared/params'; import { printHelp } from '../shared/print-help'; import { - TargetConfiguration, + ExecutorContext, WorkspaceConfiguration, Workspaces, } from '../shared/workspace'; @@ -86,13 +86,16 @@ function parseRunOpts( return res; } -export function printRunHelp(opts: RunOptions, schema: Schema) { +export function printRunHelp( + opts: { project: string; target: string }, + schema: Schema +) { printHelp(`nx run ${opts.project}:${opts.target}`, schema); } export function validateTargetAndConfiguration( workspace: WorkspaceConfiguration, - opts: RunOptions + opts: { project: string; target: string; configuration?: string } ) { const project = workspace.projects[opts.project]; if (!project) { @@ -130,28 +133,142 @@ export function validateTargetAndConfiguration( } } -export interface TargetContext { - root: string; - target: TargetConfiguration; - workspace: WorkspaceConfiguration; - projectName: string; -} - function isPromise( v: Promise<{ success: boolean }> | AsyncIterableIterator<{ success: boolean }> ): v is Promise<{ success: boolean }> { return typeof (v as any).then === 'function'; } +async function* promiseToIterator( + v: Promise<{ success: boolean }> +): AsyncIterableIterator<{ success: boolean }> { + yield await v; +} + async function iteratorToProcessStatusCode( i: AsyncIterableIterator<{ success: boolean }> ): Promise { let r; for await (r of i) { } + if (!r) { + throw new Error('NX Executor has not returned or yielded a response.'); + } return r.success ? 0 : 1; } +async function runExecutorInternal( + { + project, + target, + configuration, + }: { + project: string; + target: string; + configuration?: string; + }, + options: { [k: string]: any }, + root: string, + cwd: string, + workspace: WorkspaceConfiguration, + isVerbose: boolean, + printHelp: boolean +): Promise> { + validateTargetAndConfiguration(workspace, { + project, + target, + configuration, + }); + + const ws = new Workspaces(root); + const targetConfig = workspace.projects[project].targets[target]; + const [nodeModule, executor] = targetConfig.executor.split(':'); + const { schema, implementation } = ws.readExecutor(nodeModule, executor); + + if (printHelp) { + printRunHelp({ project, target }, schema); + process.exit(0); + } + + const combinedOptions = combineOptionsForExecutor( + options, + configuration, + targetConfig, + schema, + project, + ws.relativeCwd(cwd) + ); + + if (ws.isNxExecutor(nodeModule, executor)) { + const r = implementation(combinedOptions, { + root: root, + target: targetConfig, + workspace: workspace, + projectName: project, + cwd: cwd, + isVerbose: isVerbose, + }); + return (isPromise(r) ? promiseToIterator(r) : r) as any; + } else { + const observable = await (await import('./ngcli-adapter')).scheduleTarget( + root, + { + project, + target, + configuration, + runOptions: combinedOptions, + }, + isVerbose + ); + return eachValueFrom(observable as any); + } +} + +/** + * Loads and invokes executor. + * + * This is analogous to invoking executor from the terminal, with the exception + * that the params aren't parsed from the string, but instead provided parsed already. + * + * Apart from that, it works the same way: + * + * - it will load the workspace configuration + * - it will resolve the target + * - it will load the executor and the schema + * - it will load the options for the appropriate configuration + * - it will run the validations and will set the default + * - and, of course, it will invoke the executor + * + * Example: + * + * ```typescript + * for await (const s of await runExecutor({project: 'myproj', target: 'serve'}, {watch: true}, context)) { + * // s.success + * } + * ``` + * + * Note that the return value is a promise of an iterator, so you need to await before iterating over it. + */ +export async function runExecutor( + targetDescription: { + project: string; + target: string; + configuration?: string; + }, + options: { [k: string]: any }, + context: ExecutorContext +): Promise> { + return await runExecutorInternal( + targetDescription, + options, + context.root, + context.cwd, + context.workspace, + context.isVerbose, + false + ); +} + export async function run( cwd: string, root: string, @@ -164,49 +281,16 @@ export async function run( const workspace = ws.readWorkspaceConfiguration(); const defaultProjectName = ws.calculateDefaultProjectName(cwd, workspace); const opts = parseRunOpts(cwd, args, defaultProjectName); - validateTargetAndConfiguration(workspace, opts); - - const target = workspace.projects[opts.project].targets[opts.target]; - const [nodeModule, executor] = target.executor.split(':'); - const { schema, implementation } = ws.readExecutor(nodeModule, executor); - const combinedOptions = combineOptionsForExecutor( - opts.runOptions, - opts.configuration, - target, - schema, - defaultProjectName, - ws.relativeCwd(cwd) - ); - if (opts.help) { - printRunHelp(opts, schema); - return 0; - } - - if (ws.isNxExecutor(nodeModule, executor)) { - const r = implementation(combinedOptions, { + return iteratorToProcessStatusCode( + await runExecutorInternal( + opts, + opts.runOptions, root, - target, + cwd, workspace, - projectName: opts.project, - }); - if (isPromise(r)) { - return (await r).success ? 0 : 1; - } else { - return iteratorToProcessStatusCode(r); - } - } else { - return iteratorToProcessStatusCode( - eachValueFrom( - await (await import('./ngcli-adapter')).scheduleTarget( - root, - { - ...opts, - runOptions: combinedOptions, - }, - isVerbose - ) - ) - ); - } + isVerbose, + opts.help + ) + ); }); } diff --git a/packages/tao/src/shared/workspace.ts b/packages/tao/src/shared/workspace.ts index e427dc6f34..e05d42d879 100644 --- a/packages/tao/src/shared/workspace.ts +++ b/packages/tao/src/shared/workspace.ts @@ -173,6 +173,16 @@ export interface ExecutorContext { * The full workspace configuration */ workspace: WorkspaceConfiguration; + + /** + * The current working directory + */ + cwd: string; + + /** + * Enable verbose logging + */ + isVerbose: boolean; } export class Workspaces { diff --git a/packages/web/src/builders/file-server/file-server.impl.ts b/packages/web/src/builders/file-server/file-server.impl.ts index 191107c298..144b75cd4c 100644 --- a/packages/web/src/builders/file-server/file-server.impl.ts +++ b/packages/web/src/builders/file-server/file-server.impl.ts @@ -1,7 +1,7 @@ import { JsonObject } from '@angular-devkit/core'; import watch from 'node-watch'; import { exec, execSync } from 'child_process'; -import { TargetContext } from '@nrwl/devkit'; +import { ExecutorContext } from '@nrwl/devkit'; import ignore from 'ignore'; import { readFileSync } from 'fs-extra'; @@ -57,7 +57,7 @@ function getBuildTargetCommand(opts: FileServerOptions) { function getBuildTargetOutputPath( opts: FileServerOptions, - context: TargetContext + context: ExecutorContext ) { let buildOpts; try { @@ -95,7 +95,7 @@ function getIgnoredGlobs(root: string) { export default async function ( opts: FileServerOptions, - context: TargetContext + context: ExecutorContext ) { let changed = true; let running = false; diff --git a/scripts/documentation/generate-executors-data.ts b/scripts/documentation/generate-executors-data.ts index 17a45890ea..b1488aceb5 100644 --- a/scripts/documentation/generate-executors-data.ts +++ b/scripts/documentation/generate-executors-data.ts @@ -29,13 +29,20 @@ const registry = new CoreSchemaRegistry(); registry.addFormat(pathFormat); registry.addFormat(htmlSelectorFormat); +function readExecutorsJson(root: string) { + try { + return fs.readJsonSync(path.join(root, 'builders.json')).builders; + } catch (e) { + return fs.readJsonSync(path.join(root, 'executors.json')).executors; + } +} + function generateSchematicList( config: Configuration, registry: CoreSchemaRegistry ): Promise[] { - const builderCollectionFile = path.join(config.root, 'builders.json'); fs.removeSync(config.builderOutput); - const builderCollection = fs.readJsonSync(builderCollectionFile).builders; + const builderCollection = readExecutorsJson(config.root); return Object.keys(builderCollection).map((builderName) => { const schemaPath = path.join( config.root, diff --git a/scripts/documentation/get-package-configurations.ts b/scripts/documentation/get-package-configurations.ts index 0da6da9680..a637a75f56 100644 --- a/scripts/documentation/get-package-configurations.ts +++ b/scripts/documentation/get-package-configurations.ts @@ -44,8 +44,12 @@ export function getPackageConfigurations( framework, builderOutput: path.join(output, 'executors'), schematicOutput: path.join(output, 'generators'), - hasBuilders: itemList.includes('builders.json'), - hasSchematics: itemList.includes('collection.json'), + hasBuilders: + itemList.includes('builders.json') || + itemList.includes('executors.json'), + hasSchematics: + itemList.includes('collection.json') || + itemList.includes('generators.json'), }; }); return { framework: framework as any, configs };