Earlier this year, I wrote an Angular Schematic to automatically install the awesome UI testing framework, Cypress. If you’d like to read more about the initial release and that process, check out this article:
Switching to Cypress from Protractor in Less Than 30 Seconds
Now I’d like to walk you through the updates I’ve made to the library, including the implementation of a custom Angular CLI builder which allows you to run ng e2e to start Cypress.
As always, if you’d like to follow along and/or contribute, fork the repo on Github.
The Structure
Starting at a high level, I’ve made some changes to the library structure. As you can see below, I’ve added two directories at the src/
level: builders/
and schematics/
. Each has its own root json
file: builders.json
for builders/
and collection.json
for schematics/
. Personally, I like the specificity and organization having these files in separate directories provide.
Library Structure
The Builder
One of the nice things about writing a custom builder is that the process is very similar to writing a schematic itself. The builders.json
file is a kind of registry of the builders in your library:
{ "schema": "@angular-devkit/architect/src/builders-schema.json", "builders": { "cypress": { "implementation": "./cypress", "schema": "./cypress/schema.json", "description": "Run Cypress tests" } } }
builders/builders.json
Just like the schematic, the builder takes a json
schema and description, as well as implementation. The schema contains metadata and properties for your builder:
{ "title": "Cypress Target", "description": "Cypress target option for Build Facade", "type": "object", "properties": { "baseUrl": { "type": "string", "description": "Use this to pass directly the address of your distant server address with the port running your application" }, "browser": { "type": "string", "description": "The browser to run tests in.", "enum": ["electron", "chrome", "chromium", "canary"] }, "devServerTarget": { "type": "string", "description": "Dev server target to run tests against." }, "env": { "type": "object", "description": "A key-value Pair of environment variables to pass to Cypress runner" }, "exit": { "type": "boolean", "description": "Whether or not the Cypress Test Runner will stay open after running tests in a spec file", "default": true }, "headless": { "type": "boolean", "description": "Whether or not to open the Cypress application to run the tests. If set to 'true', will run in headless mode", "default": false }, "key": { "type": "string", "description": "The key cypress should use to run tests in parallel/record the run (CI only)" }, "parallel": { "type": "boolean", "description": "Whether or not Cypress should run its tests in parallel (CI only)", "default": false }, "record": { "type": "boolean", "description": "Whether or not Cypress should record the results of the tests", "default": false }, "spec": { "type": "string", "description": "A comma delimited glob string that is provided to the Cypress runner to specify which spec files to run. i.e. '**examples/**,**actions.spec**" }, "tsConfig": { "type": "string", "description": "The path of the Cypress tsconfig configuration json file." }, "watch": { "type": "boolean", "description": "Recompile and run tests when files change.", "default": false } }, "additionalProperties": false }
builders/cypress/schema.json
These properties are all of the potential options you’re able to pass to cypress through the command line. For a property like browser, you would use the option as --browser="chrome"
and for a boolean type property such as headless, you would just enter --headless
.
The implementation property of the builders.json
points you to the actual builder logic. In this case, I have an index.ts
file under the cypress/
directory so I use the value './cypress'
. I’m going to break this file up into chunks as we walk through it but, if you’d like to see the entire file, click here:
const cypress = require("cypress"); export default createBuilder<CypressBuilderOptions>(run); function run( options: CypressBuilderOptions, context: BuilderContext ): Observable<BuilderOutput> { options.env = options.env || {}; if (options.tsConfig) { options.env.tsConfig = join(context.workspaceRoot, options.tsConfig); } const workspace = new experimental.workspace.Workspace( normalize(context.workspaceRoot), new NodeJsSyncHost() ); return workspace.loadWorkspaceFromHost(normalize("angular.json")).pipe( map(() => os.platform() === "win32"), map(isWin => (!isWin ? workspace.root : asWindowsPath(workspace.root))), map(workspaceRoot => ({ ...options, projectPath: `${workspaceRoot}/cypress` })), switchMap(options => (!!options.devServerTarget ? startDevServer(options.devServerTarget, options.watch, context) : of(options.baseUrl) ).pipe( concatMap((baseUrl: string) => initCypress({ ...options, baseUrl })), options.watch ? tap(noop) : first(), catchError(error => of({ success: false }).pipe( tap(() => context.reportStatus(`Error: ${error.message}`)), tap(() => context.logger.error(error.message)) ) ) ) ) ); }
builders/cypress/index.ts • part I
The first thing you see here is const cypress = require('cypress');
. I initially attempted to use an es6 import, but for some reason that caused internal Cypress tests to fail. After that, I call an @angular-devkit/architect
method called createBuilder
with an explicit generic type of <CypressBuilderOptions>
and run passed in as a parameter.
import { JsonObject } from "@angular-devkit/core"; export interface CypressBuilderOptions extends JsonObject { baseUrl: string; browser: "electron" | "chrome" | "chromium" | "canary"; devServerTarget: string; env: Record<string, string>; exit: boolean; headless: boolean; key: string; parallel: boolean; projectPath: string; record: boolean; spec: string; tsConfig: string; watch: boolean; }
builders/cypress/cypress-builder-options.ts
This interface may look familiar, and that’s because it matches up with the schema.json
properties. Not everyone creates a custom interface for typing their options.The Angular.io documentation examples use options:
JsonObject
, but I like the specificity this type provides.
Back in the run method, there’s some configuration of options and a few lines which help manage workspace pathing for windows. If there is a devServerTarget
set, the startDevServer
method is called. Otherwise, Cypress is opened using the baseUrl
. Once the server has been established, the initCypress
method is called with the options
and baseUrl
. initCypress
is an Observable
of a BuilderOutput
, so I use the RxJS operator take(1)
if the watch
option is not enabled. The initCypress
method is where Cypress is launched from:
function initCypress( userOptions: CypressBuilderOptions ): Observable<BuilderOutput> { const projectFolderPath = dirname(userOptions.projectPath); const defaultOptions = { project: projectFolderPath, browser: "electron", config: {}, env: null, exit: true, headless: true, record: false, spec: "" }; const options: any = { ...defaultOptions, ...userOptions, headed: !userOptions.headless }; const { watch, headless } = userOptions; return from( cypress[!watch || headless ? "run" : "open"]({ config: options }) ).pipe( map((result: any) => ({ success: !result.totalFailed && !result.failures })) ); }
builders/cypress/index.ts • part II
After making sure options.project
points to the correct project path, I map the rest of the Cypress options to user input or default values. If the user enters the headless
option or disables watch
, cypress.run(options)
will be called. Otherwise, cypress.open(options)
will open the Cypress UI. That’s it for the custom builder, but I did make some changes to the schematic as well.
The Schematic
The biggest change to the schematic itself is wiring up the new builder. Instead of adding cypress-open
and cypress-run
scripts to the package.json
, I modify and add new CLI commands in the angular.json
.
export default function(_options: any): Rule { return (tree: Tree, _context: SchematicContext) => { _options = { ..._options, __version__: getAngularVersion(tree) }; return chain([ updateDependencies(_options), _options.removeProtractor ? removeFiles(_options) : noop(), addCypressFiles(), _options.addCypressTestScripts ? addCypressTestScriptsToPackageJson() : noop(), !_options.noBuilder ? modifyAngularJson(_options) : noop() ])(tree, _context); }; }
schematics/cypress/index.ts • part I
On line 9, I’ve added a condition on a new option, noBuilder
. If you don’t want to include the custom builder and add new commands to the angular.json, you can install using ng add @briebug/cypress-schematic --noBuilder
. By default, noBuilder
is set to false, and the modifyAngularJson
method is called:
function modifyAngularJson(options: any): Rule { return (tree: Tree, context: SchematicContext) => { if (tree.exists("./angular.json")) { const angularJsonVal = getAngularJsonValue(tree); const project = getProject(options, angularJsonVal); const cypressRunJson = { builder: "@briebug/cypress-schematic:cypress", options: { devServerTarget: `${project}:serve` }, configurations: { production: { devServerTarget: `${project}:serve:production` } } }; const cypressOpenJson = { builder: "@briebug/cypress-schematic:cypress", options: { devServerTarget: `${project}:serve`, watch: true, headless: false }, configurations: { production: { devServerTarget: `${project}:serve:production` } } }; context.logger.debug( `Adding cypress-run and cypress-open commands in angular.json` ); if (options.removeProtractor) { context.logger.debug( `Replacing e2e command with cypress-run in angular.json` ); } addNewCypressCommands( tree, angularJsonVal, project, cypressRunJson, cypressOpenJson, options.removeProtractor ); } else { throw new SchematicsException("angular.json not found"); } return tree; }; }
schematics/cypress/index.ts • part II
The major changes in this method are the creation of two new json
entries (cypressRunJson
and cypressOpenJson
) and the call to addNewCypressCommands
:
function addNewCypressCommands( tree: Tree, angularJsonVal: any, project: string, runJson: JsonObject, openJson: JsonObject, removeProtractor: boolean ) { const projectArchitectJson = angularJsonVal["projects"][project]["architect"]; projectArchitectJson["cypress-run"] = runJson; projectArchitectJson["cypress-open"] = openJson; if (removeProtractor) { projectArchitectJson["e2e"] = openJson; } return tree.overwrite( "./angular.json", JSON.stringify(angularJsonVal, null, 2) ); }
schematics/cypress/index.ts • part III
In this method, I’ve created projectArchitectJson
based on the selected project’s architect
object. I always create the new cypress-run
and cypress-open
commands and, if removeProtractor is true, I re-create the e2e command with the openJson
value. Finally, using the schematics Tree
interface I overwrite the existing angular.json
with the modified values.
This update gives us the ability to skip the package.json
scripts and tap straight into the Angular CLI through custom builders. Hopefully this article helps if you’re interested in adding your own features or modifying existing functionality in the CLI.
For more info, check out https://angular.io/guide/cli-builder.
Thanks to Minko Gechev for his suggestions which inspired this update!
Author: Anthony Jones, Sr. Enterprise Software Engineer