feat(testing): update cypress use nx devkit

This commit is contained in:
Victor Savkin 2021-01-18 15:00:44 -05:00
parent 060c451ac4
commit c40ce8a539
24 changed files with 662 additions and 1019 deletions

View File

@ -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());

View File

@ -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());

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -26,7 +26,7 @@
},
"homepage": "https://nx.dev",
"schematics": "./collection.json",
"builders": "./builders.json",
"builders": "./executors.json",
"ng-update": {
"requirements": {},
"migrations": "./migrations.json"

View File

@ -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<typeof installedCypressVersion>
> = 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
});
});
});

View File

@ -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<string, string>;
spec?: string;
copyFiles?: string;
ciBuildId?: string;
group?: string;
ignoreTestFiles?: string;
reporter?: string;
reporterOptions?: string;
}
try {
require('dotenv').config();
} catch (e) {}
export default createBuilder<CypressBuilderOptions>(cypressBuilderRunner);
/**
* @whatItDoes This is the starting point of the builder.
* @param options
* @param context
*/
export function cypressBuilderRunner(
options: CypressBuilderOptions,
context: BuilderContext
): Observable<BuilderOutput> {
// 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<string, string>,
spec?: string,
ciBuildId?: string,
group?: string,
ignoreTestFiles?: string,
reporter?: string,
reporterOptions?: string
): Observable<BuilderOutput> {
// 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<any>(
!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<string> {
// 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;
}
}

View File

@ -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<BuilderOutput> {
if (tscProcess) {
killProcess(context);
}
return Observable.create((subscriber: Subscriber<BuilderOutput>) => {
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);
}
}
});
}

View File

@ -0,0 +1,5 @@
import { convertNxExecutor } from '@nrwl/devkit';
import { default as cypressExecutor } from './cypress.impl';
export default convertNxExecutor(cypressExecutor);

View File

@ -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<typeof installedCypressVersion>
> = 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();
});
});

View File

@ -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<string, string>;
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;
}

View File

@ -3,6 +3,7 @@
"description": "Cypress target option for Build Facade",
"type": "object",
"outputCapture": "pipe",
"cli": "nx",
"properties": {
"cypressConfig": {
"type": "string",

View File

@ -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';

View File

@ -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 &&

View File

@ -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();
}

View File

@ -1,5 +1,4 @@
{
"$schema": "@angular-devkit/architect/src/builders-schema.json",
"builders": {
"jest": {
"implementation": "./src/executors/jest/compat",

View File

@ -26,7 +26,7 @@
},
"homepage": "https://nx.dev",
"schematics": "./collection.json",
"builders": "./builders.json",
"builders": "./executors.json",
"ng-update": {
"requirements": {},
"migrations": "./migrations.json"

View File

@ -41,6 +41,8 @@ describe('Jest Executor', () => {
target: {
executor: '@nrwl/jest:jest',
},
cwd: '/root',
isVerbose: true,
};
});

View File

@ -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<Observable<BuilderOutput>> {
const logger = getLogger(verbose);
@ -72,6 +77,7 @@ export async function scheduleTarget(
opts.runOptions,
{ logger }
);
return run.output;
}

View File

@ -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<number> {
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<T extends { success: boolean }>(
{
project,
target,
configuration,
}: {
project: string;
target: string;
configuration?: string;
},
options: { [k: string]: any },
root: string,
cwd: string,
workspace: WorkspaceConfiguration,
isVerbose: boolean,
printHelp: boolean
): Promise<AsyncIterableIterator<T>> {
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<T>(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<T extends { success: boolean }>(
targetDescription: {
project: string;
target: string;
configuration?: string;
},
options: { [k: string]: any },
context: ExecutorContext
): Promise<AsyncIterableIterator<T>> {
return await runExecutorInternal<T>(
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
)
);
});
}

View File

@ -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 {

View File

@ -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;

View File

@ -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<FileSystemSchematicJsonDescription>[] {
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,

View File

@ -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 };