summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/ng-schematics/src
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/ng-schematics/src')
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json10
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts200
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json26
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts15
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json20
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs4
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts39
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json8
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template18
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts118
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json34
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template2
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template20
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template60
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template10
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json10
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js10
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js4
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts135
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json37
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts152
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts45
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts189
-rw-r--r--remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts47
24 files changed, 1213 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json
new file mode 100644
index 0000000000..41079f7731
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../../../node_modules/@angular-devkit/architect/src/builders-schema.json",
+ "builders": {
+ "puppeteer": {
+ "implementation": "./puppeteer",
+ "schema": "./puppeteer/schema.json",
+ "description": "Run e2e test with Puppeteer"
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts
new file mode 100644
index 0000000000..82a1e8e7da
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts
@@ -0,0 +1,200 @@
+import {spawn} from 'child_process';
+import {normalize, join} from 'path';
+
+import {
+ createBuilder,
+ type BuilderContext,
+ type BuilderOutput,
+ targetFromTargetString,
+ type BuilderRun,
+} from '@angular-devkit/architect';
+import type {JsonObject} from '@angular-devkit/core';
+
+import {TestRunner} from '../../schematics/utils/types.js';
+
+import type {PuppeteerBuilderOptions} from './types.js';
+
+const terminalStyles = {
+ cyan: '\u001b[36;1m',
+ green: '\u001b[32m',
+ red: '\u001b[31m',
+ bold: '\u001b[1m',
+ reverse: '\u001b[7m',
+ clear: '\u001b[0m',
+};
+
+export function getCommandForRunner(runner: TestRunner): [string, ...string[]] {
+ switch (runner) {
+ case TestRunner.Jasmine:
+ return [`jasmine`, '--config=./e2e/jasmine.json'];
+ case TestRunner.Jest:
+ return [`jest`, '-c', 'e2e/jest.config.js'];
+ case TestRunner.Mocha:
+ return [`mocha`, '--config=./e2e/.mocharc.js'];
+ case TestRunner.Node:
+ return ['node', '--test', '--test-reporter', 'spec', 'e2e/build/'];
+ }
+
+ throw new Error(`Unknown test runner ${runner}!`);
+}
+
+function getExecutable(command: string[]) {
+ const executable = command.shift()!;
+ const debugError = `Error running '${executable}' with arguments '${command.join(
+ ' '
+ )}'.`;
+
+ return {
+ executable,
+ args: command,
+ debugError,
+ error: 'Please look at the output above to determine the issue!',
+ };
+}
+
+function updateExecutablePath(command: string, root?: string) {
+ if (command === TestRunner.Node) {
+ return command;
+ }
+
+ let path = 'node_modules/.bin/';
+ if (root && root !== '') {
+ const nested = root
+ .split('/')
+ .map(() => {
+ return '../';
+ })
+ .join('');
+ path = `${nested}${path}${command}`;
+ } else {
+ path = `./${path}${command}`;
+ }
+
+ return normalize(path);
+}
+
+async function executeCommand(
+ context: BuilderContext,
+ command: string[],
+ env: NodeJS.ProcessEnv = {}
+) {
+ let project: JsonObject;
+ if (context.target) {
+ project = await context.getProjectMetadata(context.target.project);
+ command[0] = updateExecutablePath(command[0]!, String(project['root']));
+ }
+
+ await new Promise(async (resolve, reject) => {
+ context.logger.debug(`Trying to execute command - ${command.join(' ')}.`);
+ const {executable, args, debugError, error} = getExecutable(command);
+ let path = context.workspaceRoot;
+ if (context.target) {
+ path = join(path, (project['root'] as string | undefined) ?? '');
+ }
+
+ const child = spawn(executable, args, {
+ cwd: path,
+ stdio: 'inherit',
+ shell: true,
+ env: {
+ ...process.env,
+ ...env,
+ },
+ });
+
+ child.on('error', message => {
+ context.logger.debug(debugError);
+ console.log(message);
+ reject(error);
+ });
+
+ child.on('exit', code => {
+ if (code === 0) {
+ resolve(true);
+ } else {
+ reject(error);
+ }
+ });
+ });
+}
+
+function message(
+ message: string,
+ context: BuilderContext,
+ type: 'info' | 'success' | 'error' = 'info'
+): void {
+ let style: string;
+ switch (type) {
+ case 'info':
+ style = terminalStyles.reverse + terminalStyles.cyan;
+ break;
+ case 'success':
+ style = terminalStyles.reverse + terminalStyles.green;
+ break;
+ case 'error':
+ style = terminalStyles.red;
+ break;
+ }
+ context.logger.info(
+ `${terminalStyles.bold}${style}${message}${terminalStyles.clear}`
+ );
+}
+
+async function startServer(
+ options: PuppeteerBuilderOptions,
+ context: BuilderContext
+): Promise<BuilderRun> {
+ context.logger.debug('Trying to start server.');
+ const target = targetFromTargetString(options.devServerTarget);
+ const defaultServerOptions = await context.getTargetOptions(target);
+
+ const overrides = {
+ watch: false,
+ host: defaultServerOptions['host'],
+ port: options.port ?? defaultServerOptions['port'],
+ } as JsonObject;
+
+ message(' Spawning test server โš™๏ธ ... \n', context);
+ const server = await context.scheduleTarget(target, overrides);
+ const result = await server.result;
+ if (!result.success) {
+ throw new Error('Failed to spawn server! Stopping tests...');
+ }
+
+ return server;
+}
+
+async function executeE2ETest(
+ options: PuppeteerBuilderOptions,
+ context: BuilderContext
+): Promise<BuilderOutput> {
+ let server: BuilderRun | null = null;
+ try {
+ message('\n Building tests ๐Ÿ› ๏ธ ... \n', context);
+ await executeCommand(context, [`tsc`, '-p', 'e2e/tsconfig.json']);
+
+ server = await startServer(options, context);
+ const result = await server.result;
+
+ message('\n Running tests ๐Ÿงช ... \n', context);
+ const testRunnerCommand = getCommandForRunner(options.testRunner);
+ await executeCommand(context, testRunnerCommand, {
+ baseUrl: result['baseUrl'],
+ });
+
+ message('\n ๐Ÿš€ Test ran successfully! ๐Ÿš€ ', context, 'success');
+ return {success: true};
+ } catch (error) {
+ message('\n ๐Ÿ›‘ Test failed! ๐Ÿ›‘ ', context, 'error');
+ if (error instanceof Error) {
+ return {success: false, error: error.message};
+ }
+ return {success: false, error: error as string};
+ } finally {
+ if (server) {
+ await server.stop();
+ }
+ }
+}
+
+export default createBuilder<PuppeteerBuilderOptions>(executeE2ETest);
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json
new file mode 100644
index 0000000000..2693d19cce
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json
@@ -0,0 +1,26 @@
+{
+ "title": "Puppeteer",
+ "description": "Options for Puppeteer Angular Schematics",
+ "type": "object",
+ "properties": {
+ "commands": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "item": {
+ "type": "string"
+ }
+ },
+ "description": "Commands to execute in the repo. Commands prefixed with `./node_modules/bin` (Exception: 'node')."
+ },
+ "devServerTarget": {
+ "type": "string",
+ "description": "Angular target that spawns the server."
+ },
+ "port": {
+ "type": ["number", "null"],
+ "description": "Port to run the test server on."
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts
new file mode 100644
index 0000000000..6258a955c0
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {JsonObject} from '@angular-devkit/core';
+
+import type {TestRunner} from '../../schematics/utils/types.js';
+
+export interface PuppeteerBuilderOptions extends JsonObject {
+ testRunner: TestRunner;
+ devServerTarget: string;
+ port: number | null;
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json
new file mode 100644
index 0000000000..00bede45e5
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json",
+ "schematics": {
+ "ng-add": {
+ "description": "Add Puppeteer to an Angular project",
+ "factory": "./ng-add/index#ngAdd",
+ "schema": "./ng-add/schema.json"
+ },
+ "e2e": {
+ "description": "Create a single test file",
+ "factory": "./e2e/index#e2e",
+ "schema": "./e2e/schema.json"
+ },
+ "config": {
+ "description": "Eject Puppeteer config file",
+ "factory": "./config/index#config",
+ "schema": "./config/schema.json"
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs
new file mode 100644
index 0000000000..0da14a80d8
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs
@@ -0,0 +1,4 @@
+/**
+ * @type {import("puppeteer").Configuration}
+ */
+export {};
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts
new file mode 100644
index 0000000000..b01d98e33e
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ chain,
+ type Rule,
+ type SchematicContext,
+ type Tree,
+} from '@angular-devkit/schematics';
+
+import {addFilesSingle} from '../utils/files.js';
+import {TestRunner, type AngularProject} from '../utils/types.js';
+
+// You don't have to export the function as default. You can also have more than one rule
+// factory per file.
+export function config(): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ return chain([addPuppeteerConfig()])(tree, context);
+ };
+}
+
+function addPuppeteerConfig(): Rule {
+ return (_tree: Tree, context: SchematicContext) => {
+ context.logger.debug('Adding Puppeteer config file.');
+
+ return addFilesSingle('', {root: ''} as AngularProject, {
+ // No-op here to fill types
+ options: {
+ testRunner: TestRunner.Jasmine,
+ port: 4200,
+ },
+ applyPath: './files',
+ relativeToWorkspacePath: `/`,
+ });
+ };
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json
new file mode 100644
index 0000000000..8d45751bb1
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "Puppeteer",
+ "title": "Puppeteer Config Schema",
+ "type": "object",
+ "properties": {},
+ "required": []
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template
new file mode 100644
index 0000000000..ca90f258b8
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template
@@ -0,0 +1,18 @@
+<% if(testRunner == 'node') { %>
+import * as assert from 'assert';
+import {describe, it} from 'node:test';
+<% } %><% if(testRunner == 'mocha') { %>
+import * as assert from 'assert';
+<% } %>
+import {setupBrowserHooks, getBrowserState} from './utils';
+
+describe('<%= classify(name) %>', function () {
+ <% if(route) { %>
+ setupBrowserHooks('<%= route %>');
+ <% } else { %>
+ setupBrowserHooks();
+ <% } %>
+ it('', async function () {
+ const {page} = getBrowserState();
+ });
+});
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts
new file mode 100644
index 0000000000..cf1f634f94
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ chain,
+ type Rule,
+ type SchematicContext,
+ SchematicsException,
+ type Tree,
+} from '@angular-devkit/schematics';
+
+import {addCommonFiles} from '../utils/files.js';
+import {getApplicationProjects} from '../utils/json.js';
+import {
+ TestRunner,
+ type SchematicsSpec,
+ type AngularProject,
+ type PuppeteerSchematicsConfig,
+} from '../utils/types.js';
+
+// You don't have to export the function as default. You can also have more than one rule
+// factory per file.
+export function e2e(userArgs: Record<string, string>): Rule {
+ const options = parseUserTestArgs(userArgs);
+
+ return (tree: Tree, context: SchematicContext) => {
+ return chain([addE2EFile(options)])(tree, context);
+ };
+}
+
+function parseUserTestArgs(userArgs: Record<string, string>): SchematicsSpec {
+ const options: Partial<SchematicsSpec> = {
+ ...userArgs,
+ };
+ if ('p' in userArgs) {
+ options['project'] = userArgs['p'];
+ }
+ if ('n' in userArgs) {
+ options['name'] = userArgs['n'];
+ }
+ if ('r' in userArgs) {
+ options['route'] = userArgs['r'];
+ }
+
+ if (options['route'] && options['route'].startsWith('/')) {
+ options['route'] = options['route'].substring(1);
+ }
+
+ return options as SchematicsSpec;
+}
+
+function findTestingOption<
+ Property extends keyof PuppeteerSchematicsConfig['options'],
+>(
+ [name, project]: [string, AngularProject | undefined],
+ property: Property
+): PuppeteerSchematicsConfig['options'][Property] {
+ if (!project) {
+ throw new Error(`Project "${name}" not found.`);
+ }
+
+ const e2e = project.architect?.e2e;
+ const puppeteer = project.architect?.puppeteer;
+ const builder = '@puppeteer/ng-schematics:puppeteer';
+
+ if (e2e?.builder === builder) {
+ return e2e.options[property];
+ } else if (puppeteer?.builder === builder) {
+ return puppeteer.options[property];
+ }
+
+ throw new Error(`Can't find property "${property}" for project "${name}".`);
+}
+
+function addE2EFile(options: SchematicsSpec): Rule {
+ return async (tree: Tree, context: SchematicContext) => {
+ context.logger.debug('Adding Spec file.');
+
+ const projects = getApplicationProjects(tree);
+ const projectNames = Object.keys(projects) as [string, ...string[]];
+ const foundProject: [string, AngularProject | undefined] | undefined =
+ projectNames.length === 1
+ ? [projectNames[0], projects[projectNames[0]]]
+ : Object.entries(projects).find(([name, project]) => {
+ return options.project
+ ? options.project === name
+ : project.root === '';
+ });
+ if (!foundProject) {
+ throw new SchematicsException(
+ `Project not found! Please run "ng generate @puppeteer/ng-schematics:test <Test> <Project>"`
+ );
+ }
+
+ const testRunner = findTestingOption(foundProject, 'testRunner');
+ const port = findTestingOption(foundProject, 'port');
+
+ context.logger.debug('Creating Spec file.');
+
+ return addCommonFiles(
+ {[foundProject[0]]: foundProject[1]} as Record<string, AngularProject>,
+ {
+ options: {
+ name: options.name,
+ route: options.route,
+ testRunner,
+ // Node test runner does not support glob patterns
+ // It looks for files `*.test.js`
+ ext: testRunner === TestRunner.Node ? 'test' : 'e2e',
+ port,
+ },
+ }
+ );
+ };
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json
new file mode 100644
index 0000000000..7752c9ceef
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "Puppeteer",
+ "title": "Puppeteer E2E Schema",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "alias": "n",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "Name for spec to be created:"
+ },
+ "project": {
+ "type": "string",
+ "$default": {
+ "$source": "argv",
+ "index": 1
+ },
+ "alias": "p"
+ },
+ "route": {
+ "type": "string",
+ "$default": {
+ "$source": "argv",
+ "index": 1
+ },
+ "alias": "r"
+ }
+ },
+ "required": []
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template
new file mode 100644
index 0000000000..f038b2eb67
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template
@@ -0,0 +1,2 @@
+# Compiled e2e tests output
+build/
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template
new file mode 100644
index 0000000000..60637d0fa7
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template
@@ -0,0 +1,20 @@
+<% if(testRunner == 'node') { %>
+import * as assert from 'assert';
+import {describe, it} from 'node:test';
+<% } %><% if(testRunner == 'mocha') { %>
+import * as assert from 'assert';
+<% } %>
+import {setupBrowserHooks, getBrowserState} from './utils';
+
+describe('App test', function () {
+ setupBrowserHooks();
+ it('is running', async function () {
+ const {page} = getBrowserState();
+ const element = await page.locator('::-p-text(<%= project %>)').wait();
+<% if(testRunner == 'jasmine' || testRunner == 'jest') { %>
+ expect(element).not.toBeNull();
+<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %>
+ assert.ok(element);
+<% } %>
+ });
+});
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template
new file mode 100644
index 0000000000..2136f99a3a
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template
@@ -0,0 +1,60 @@
+<% if(testRunner == 'node') { %>
+import {before, beforeEach, after, afterEach} from 'node:test';
+<% } %>
+import * as puppeteer from 'puppeteer';
+
+const baseUrl = process.env['baseUrl'] ?? '<%= baseUrl %>';
+let browser: puppeteer.Browser;
+let page: puppeteer.Page;
+
+export function setupBrowserHooks(path = ''): void {
+<% if(testRunner == 'jasmine' || testRunner == 'jest') { %>
+ beforeAll(async () => {
+ browser = await puppeteer.launch({
+ headless: 'new'
+ });
+ });
+<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %>
+ before(async () => {
+ browser = await puppeteer.launch({
+ headless: 'new'
+ });
+ });
+<% } %>
+
+ beforeEach(async () => {
+ page = await browser.newPage();
+ await page.goto(`${baseUrl}${path}`);
+ });
+
+ afterEach(async () => {
+ await page?.close();
+ });
+
+<% if(testRunner == 'jasmine' || testRunner == 'jest') { %>
+ afterAll(async () => {
+ await browser?.close();
+ });
+<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %>
+ after(async () => {
+ await browser?.close();
+ });
+<% } %>
+}
+
+export function getBrowserState(): {
+ browser: puppeteer.Browser;
+ page: puppeteer.Page;
+ baseUrl: string;
+} {
+ if (!browser) {
+ throw new Error(
+ 'No browser state found! Ensure `setupBrowserHooks()` is called.'
+ );
+ }
+ return {
+ browser,
+ page,
+ baseUrl,
+ };
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template
new file mode 100644
index 0000000000..38501b89ef
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template
@@ -0,0 +1,10 @@
+{
+ "extends": "<%= tsConfigPath %>",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "rootDir": "tests/",
+ "outDir": "build/",
+ "types": ["<%= testRunner %>"]
+ },
+ "include": ["tests/**/*.ts"]
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json
new file mode 100644
index 0000000000..ad5dc6fbce
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json
@@ -0,0 +1,10 @@
+{
+ "spec_dir": "e2e",
+ "spec_files": ["**/*[eE]2[eE].js"],
+ "helpers": ["helpers/**/*.?(m)js"],
+ "env": {
+ "failSpecWithNoExpectations": true,
+ "stopSpecOnExpectationFailure": false,
+ "random": true
+ }
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js
new file mode 100644
index 0000000000..ee21c6737e
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js
@@ -0,0 +1,10 @@
+/*
+ * For a detailed explanation regarding each configuration property and type check, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+/** @type {import('jest').Config} */
+module.exports = {
+ testMatch: ['<rootDir>/build/**/*.e2e.js'],
+ testEnvironment: 'node',
+};
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js
new file mode 100644
index 0000000000..28c1839674
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ spec: './e2e/build/**/*.e2e.js',
+ timeout: 5000,
+};
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts
new file mode 100644
index 0000000000..1f962e0cfc
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts
@@ -0,0 +1,135 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ chain,
+ type Rule,
+ type SchematicContext,
+ type Tree,
+} from '@angular-devkit/schematics';
+import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks';
+import {of} from 'rxjs';
+import {concatMap, map, scan} from 'rxjs/operators';
+
+import {
+ addCommonFiles as addCommonFilesHelper,
+ addFrameworkFiles,
+ getNgCommandName,
+ hasE2ETester,
+} from '../utils/files.js';
+import {getApplicationProjects} from '../utils/json.js';
+import {
+ addPackageJsonDependencies,
+ addPackageJsonScripts,
+ getDependenciesFromOptions,
+ getPackageLatestNpmVersion,
+ DependencyType,
+ type NodePackage,
+ updateAngularJsonScripts,
+} from '../utils/packages.js';
+import {TestRunner, type SchematicsOptions} from '../utils/types.js';
+
+const DEFAULT_PORT = 4200;
+
+// You don't have to export the function as default. You can also have more than one rule
+// factory per file.
+export function ngAdd(options: SchematicsOptions): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ return chain([
+ addDependencies(options),
+ addCommonFiles(options),
+ addOtherFiles(options),
+ updateScripts(),
+ updateAngularConfig(options),
+ ])(tree, context);
+ };
+}
+
+function addDependencies(options: SchematicsOptions): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ context.logger.debug('Adding dependencies to "package.json"');
+ const dependencies = getDependenciesFromOptions(options);
+
+ return of(...dependencies).pipe(
+ concatMap((packageName: string) => {
+ return getPackageLatestNpmVersion(packageName);
+ }),
+ scan((array, nodePackage) => {
+ array.push(nodePackage);
+ return array;
+ }, [] as NodePackage[]),
+ map(packages => {
+ context.logger.debug('Updating dependencies...');
+ addPackageJsonDependencies(tree, packages, DependencyType.Dev);
+ context.addTask(
+ new NodePackageInstallTask({
+ // Trigger Post-Install hooks to download the browser
+ allowScripts: true,
+ })
+ );
+
+ return tree;
+ })
+ );
+ };
+}
+
+function updateScripts(): Rule {
+ return (tree: Tree, context: SchematicContext): Tree => {
+ context.logger.debug('Updating "package.json" scripts');
+ const projects = getApplicationProjects(tree);
+ const projectsKeys = Object.keys(projects);
+
+ if (projectsKeys.length === 1) {
+ const name = getNgCommandName(projects);
+ const prefix = hasE2ETester(projects) ? `run ${projectsKeys[0]}:` : '';
+ return addPackageJsonScripts(tree, [
+ {
+ name,
+ script: `ng ${prefix}${name}`,
+ },
+ ]);
+ }
+ return tree;
+ };
+}
+
+function addCommonFiles(options: SchematicsOptions): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ context.logger.debug('Adding Puppeteer base files.');
+ const projects = getApplicationProjects(tree);
+
+ return addCommonFilesHelper(projects, {
+ options: {
+ ...options,
+ port: DEFAULT_PORT,
+ ext: options.testRunner === TestRunner.Node ? 'test' : 'e2e',
+ },
+ });
+ };
+}
+
+function addOtherFiles(options: SchematicsOptions): Rule {
+ return (tree: Tree, context: SchematicContext) => {
+ context.logger.debug('Adding Puppeteer additional files.');
+ const projects = getApplicationProjects(tree);
+
+ return addFrameworkFiles(projects, {
+ options: {
+ ...options,
+ port: DEFAULT_PORT,
+ },
+ });
+ };
+}
+
+function updateAngularConfig(options: SchematicsOptions): Rule {
+ return (tree: Tree, context: SchematicContext): Tree => {
+ context.logger.debug('Updating "angular.json".');
+
+ return updateAngularJsonScripts(tree, options);
+ };
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json
new file mode 100644
index 0000000000..0fa581f1a7
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "$id": "Puppeteer",
+ "title": "Puppeteer Install Schema",
+ "type": "object",
+ "properties": {
+ "testRunner": {
+ "type": "string",
+ "enum": ["jasmine", "jest", "mocha", "node"],
+ "default": "jasmine",
+ "alias": "t",
+ "x-prompt": {
+ "message": "Which test runners do you wish to use?",
+ "type": "list",
+ "items": [
+ {
+ "value": "jasmine",
+ "label": "Use Jasmine [https://jasmine.github.io/]"
+ },
+ {
+ "value": "jest",
+ "label": "Use Jest [https://jestjs.io/]"
+ },
+ {
+ "value": "mocha",
+ "label": "Use Mocha [https://mochajs.org/]"
+ },
+ {
+ "value": "node",
+ "label": "Use Node Test Runner [https://nodejs.org/api/test.html]"
+ }
+ ]
+ }
+ }
+ },
+ "required": []
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts
new file mode 100644
index 0000000000..4d255062b4
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {relative, resolve} from 'path';
+
+import {getSystemPath, normalize, strings} from '@angular-devkit/core';
+import type {Rule} from '@angular-devkit/schematics';
+import {
+ apply,
+ applyTemplates,
+ chain,
+ mergeWith,
+ move,
+ url,
+} from '@angular-devkit/schematics';
+
+import type {AngularProject, TestRunner} from './types.js';
+
+export interface FilesOptions {
+ options: {
+ testRunner: TestRunner;
+ port: number;
+ name?: string;
+ exportConfig?: boolean;
+ ext?: string;
+ route?: string;
+ };
+ applyPath: string;
+ relativeToWorkspacePath: string;
+ movePath?: string;
+}
+
+export function addFilesToProjects(
+ projects: Record<string, AngularProject>,
+ options: FilesOptions
+): Rule {
+ return chain(
+ Object.keys(projects).map(name => {
+ return addFilesSingle(name, projects[name] as AngularProject, options);
+ })
+ );
+}
+
+export function addFilesSingle(
+ name: string,
+ project: AngularProject,
+ {options, applyPath, movePath, relativeToWorkspacePath}: FilesOptions
+): Rule {
+ const projectPath = resolve(getSystemPath(normalize(project.root)));
+ const workspacePath = resolve(getSystemPath(normalize('')));
+
+ const relativeToWorkspace = relative(
+ `${projectPath}${relativeToWorkspacePath}`,
+ workspacePath
+ );
+
+ const baseUrl = getProjectBaseUrl(project, options.port);
+ const tsConfigPath = getTsConfigPath(project);
+
+ return mergeWith(
+ apply(url(applyPath), [
+ move(movePath ? `${project.root}${movePath}` : project.root),
+ applyTemplates({
+ ...options,
+ ...strings,
+ root: project.root ? `${project.root}/` : project.root,
+ baseUrl,
+ tsConfigPath,
+ project: name,
+ relativeToWorkspace,
+ }),
+ ])
+ );
+}
+
+function getProjectBaseUrl(project: AngularProject, port: number): string {
+ let options = {protocol: 'http', port, host: 'localhost'};
+
+ if (project.architect?.serve?.options) {
+ const projectOptions = project.architect?.serve?.options;
+ const projectPort = port !== 4200 ? port : projectOptions?.port ?? port;
+ options = {...options, ...projectOptions, port: projectPort};
+ options.protocol = projectOptions.ssl ? 'https' : 'http';
+ }
+
+ return `${options.protocol}://${options.host}:${options.port}/`;
+}
+
+function getTsConfigPath(project: AngularProject): string {
+ const filename = 'tsconfig.json';
+
+ if (!project.root) {
+ return `../${filename}`;
+ }
+
+ const nested = project.root
+ .split('/')
+ .map(() => {
+ return '../';
+ })
+ .join('');
+
+ // Prepend a single `../` as we put the test inside `e2e` folder
+ return `../${nested}${filename}`;
+}
+
+export function addCommonFiles(
+ projects: Record<string, AngularProject>,
+ filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'>
+): Rule {
+ const options: FilesOptions = {
+ ...filesOptions,
+ applyPath: './files/common',
+ relativeToWorkspacePath: `/`,
+ };
+
+ return addFilesToProjects(projects, options);
+}
+
+export function addFrameworkFiles(
+ projects: Record<string, AngularProject>,
+ filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'>
+): Rule {
+ const testRunner = filesOptions.options.testRunner;
+ const options: FilesOptions = {
+ ...filesOptions,
+ applyPath: `./files/${testRunner}`,
+ relativeToWorkspacePath: `/`,
+ };
+
+ return addFilesToProjects(projects, options);
+}
+
+export function hasE2ETester(
+ projects: Record<string, AngularProject>
+): boolean {
+ return Object.values(projects).some((project: AngularProject) => {
+ return Boolean(project.architect?.e2e);
+ });
+}
+
+export function getNgCommandName(
+ projects: Record<string, AngularProject>
+): string {
+ if (!hasE2ETester(projects)) {
+ return 'e2e';
+ }
+ return 'puppeteer';
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts
new file mode 100644
index 0000000000..1a38d638a7
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {SchematicsException, type Tree} from '@angular-devkit/schematics';
+
+import type {AngularJson, AngularProject} from './types.js';
+
+export function getJsonFileAsObject(
+ tree: Tree,
+ path: string
+): Record<string, unknown> {
+ try {
+ const buffer = tree.read(path) as Buffer;
+ const content = buffer.toString();
+ return JSON.parse(content);
+ } catch {
+ throw new SchematicsException(`Unable to retrieve file at ${path}.`);
+ }
+}
+
+export function getObjectAsJson(object: Record<string, unknown>): string {
+ return JSON.stringify(object, null, 2);
+}
+
+export function getAngularConfig(tree: Tree): AngularJson {
+ return getJsonFileAsObject(tree, './angular.json') as unknown as AngularJson;
+}
+
+export function getApplicationProjects(
+ tree: Tree
+): Record<string, AngularProject> {
+ const {projects} = getAngularConfig(tree);
+
+ const applications: Record<string, AngularProject> = {};
+ for (const key in projects) {
+ const project = projects[key]!;
+ if (project.projectType === 'application') {
+ applications[key] = project;
+ }
+ }
+ return applications;
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts
new file mode 100644
index 0000000000..6ef8ef6002
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {get} from 'https';
+
+import type {Tree} from '@angular-devkit/schematics';
+
+import {getNgCommandName} from './files.js';
+import {
+ getAngularConfig,
+ getApplicationProjects,
+ getJsonFileAsObject,
+ getObjectAsJson,
+} from './json.js';
+import {type SchematicsOptions, TestRunner} from './types.js';
+export interface NodePackage {
+ name: string;
+ version: string;
+}
+export interface NodeScripts {
+ name: string;
+ script: string;
+}
+
+export enum DependencyType {
+ Default = 'dependencies',
+ Dev = 'devDependencies',
+ Peer = 'peerDependencies',
+ Optional = 'optionalDependencies',
+}
+
+export function getPackageLatestNpmVersion(name: string): Promise<NodePackage> {
+ return new Promise(resolve => {
+ let version = 'latest';
+
+ return get(`https://registry.npmjs.org/${name}`, res => {
+ let data = '';
+
+ res.on('data', chunk => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ try {
+ const response = JSON.parse(data);
+ version = response?.['dist-tags']?.latest ?? version;
+ } catch {
+ } finally {
+ resolve({
+ name,
+ version,
+ });
+ }
+ });
+ }).on('error', () => {
+ resolve({
+ name,
+ version,
+ });
+ });
+ });
+}
+
+function updateJsonValues(
+ json: Record<string, any>,
+ target: string,
+ updates: Array<{name: string; value: any}>,
+ overwrite = false
+) {
+ updates.forEach(({name, value}) => {
+ if (!json[target][name] || overwrite) {
+ json[target] = {
+ ...json[target],
+ [name]: value,
+ };
+ }
+ });
+}
+
+export function addPackageJsonDependencies(
+ tree: Tree,
+ packages: NodePackage[],
+ type: DependencyType,
+ overwrite?: boolean,
+ fileLocation = './package.json'
+): Tree {
+ const packageJson = getJsonFileAsObject(tree, fileLocation);
+
+ updateJsonValues(
+ packageJson,
+ type,
+ packages.map(({name, version}) => {
+ return {name, value: version};
+ }),
+ overwrite
+ );
+
+ tree.overwrite(fileLocation, getObjectAsJson(packageJson));
+
+ return tree;
+}
+
+export function getDependenciesFromOptions(
+ options: SchematicsOptions
+): string[] {
+ const dependencies = ['puppeteer'];
+
+ switch (options.testRunner) {
+ case TestRunner.Jasmine:
+ dependencies.push('jasmine');
+ break;
+ case TestRunner.Jest:
+ dependencies.push('jest', '@types/jest');
+ break;
+ case TestRunner.Mocha:
+ dependencies.push('mocha', '@types/mocha');
+ break;
+ case TestRunner.Node:
+ dependencies.push('@types/node');
+ break;
+ }
+
+ return dependencies;
+}
+
+export function addPackageJsonScripts(
+ tree: Tree,
+ scripts: NodeScripts[],
+ overwrite?: boolean,
+ fileLocation = './package.json'
+): Tree {
+ const packageJson = getJsonFileAsObject(tree, fileLocation);
+
+ updateJsonValues(
+ packageJson,
+ 'scripts',
+ scripts.map(({name, script}) => {
+ return {name, value: script};
+ }),
+ overwrite
+ );
+
+ tree.overwrite(fileLocation, getObjectAsJson(packageJson));
+
+ return tree;
+}
+
+export function updateAngularJsonScripts(
+ tree: Tree,
+ options: SchematicsOptions,
+ overwrite = true
+): Tree {
+ const angularJson = getAngularConfig(tree);
+ const projects = getApplicationProjects(tree);
+ const name = getNgCommandName(projects);
+
+ Object.keys(projects).forEach(project => {
+ const e2eScript = [
+ {
+ name,
+ value: {
+ builder: '@puppeteer/ng-schematics:puppeteer',
+ options: {
+ devServerTarget: `${project}:serve`,
+ testRunner: options.testRunner,
+ },
+ configurations: {
+ production: {
+ devServerTarget: `${project}:serve:production`,
+ },
+ },
+ },
+ },
+ ];
+
+ updateJsonValues(
+ angularJson['projects'][project]!,
+ 'architect',
+ e2eScript,
+ overwrite
+ );
+ });
+
+ tree.overwrite('./angular.json', getObjectAsJson(angularJson as any));
+
+ return tree;
+}
diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts
new file mode 100644
index 0000000000..7d66e0f0fa
--- /dev/null
+++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export enum TestRunner {
+ Jasmine = 'jasmine',
+ Jest = 'jest',
+ Mocha = 'mocha',
+ Node = 'node',
+}
+
+export interface SchematicsOptions {
+ testRunner: TestRunner;
+}
+
+export interface PuppeteerSchematicsConfig {
+ builder: string;
+ options: {
+ port: number;
+ testRunner: TestRunner;
+ };
+}
+export interface AngularProject {
+ projectType: 'application' | 'library';
+ root: string;
+ architect: {
+ e2e?: PuppeteerSchematicsConfig;
+ puppeteer?: PuppeteerSchematicsConfig;
+ serve: {
+ options: {
+ ssl: string;
+ port: number;
+ };
+ };
+ };
+}
+export interface AngularJson {
+ projects: Record<string, AngularProject>;
+}
+
+export interface SchematicsSpec {
+ name: string;
+ project?: string;
+ route?: string;
+}