feat(testing): update cypress use nx devkit
This commit is contained in:
parent
060c451ac4
commit
c40ce8a539
@ -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());
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/cypress/executors.json
Normal file
16
packages/cypress/executors.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,7 @@
|
||||
},
|
||||
"homepage": "https://nx.dev",
|
||||
"schematics": "./collection.json",
|
||||
"builders": "./builders.json",
|
||||
"builders": "./executors.json",
|
||||
"ng-update": {
|
||||
"requirements": {},
|
||||
"migrations": "./migrations.json"
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
5
packages/cypress/src/executors/cypress/compat.ts
Normal file
5
packages/cypress/src/executors/cypress/compat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { convertNxExecutor } from '@nrwl/devkit';
|
||||
|
||||
import { default as cypressExecutor } from './cypress.impl';
|
||||
|
||||
export default convertNxExecutor(cypressExecutor);
|
||||
256
packages/cypress/src/executors/cypress/cypress.impl.spec.ts
Normal file
256
packages/cypress/src/executors/cypress/cypress.impl.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
183
packages/cypress/src/executors/cypress/cypress.impl.ts
Normal file
183
packages/cypress/src/executors/cypress/cypress.impl.ts
Normal 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;
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
"description": "Cypress target option for Build Facade",
|
||||
"type": "object",
|
||||
"outputCapture": "pipe",
|
||||
"cli": "nx",
|
||||
"properties": {
|
||||
"cypressConfig": {
|
||||
"type": "string",
|
||||
@ -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';
|
||||
|
||||
@ -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 &&
|
||||
|
||||
20
packages/devkit/src/utils/strip-indents.ts
Normal file
20
packages/devkit/src/utils/strip-indents.ts
Normal 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();
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "@angular-devkit/architect/src/builders-schema.json",
|
||||
"builders": {
|
||||
"jest": {
|
||||
"implementation": "./src/executors/jest/compat",
|
||||
@ -26,7 +26,7 @@
|
||||
},
|
||||
"homepage": "https://nx.dev",
|
||||
"schematics": "./collection.json",
|
||||
"builders": "./builders.json",
|
||||
"builders": "./executors.json",
|
||||
"ng-update": {
|
||||
"requirements": {},
|
||||
"migrations": "./migrations.json"
|
||||
|
||||
@ -41,6 +41,8 @@ describe('Jest Executor', () => {
|
||||
target: {
|
||||
executor: '@nrwl/jest:jest',
|
||||
},
|
||||
cwd: '/root',
|
||||
isVerbose: true,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user