diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/packages/ng-schematics/src | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/packages/ng-schematics/src')
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; +} |