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 | |
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')
40 files changed, 2523 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/ng-schematics/.eslintignore b/remote/test/puppeteer/packages/ng-schematics/.eslintignore new file mode 100644 index 0000000000..8424d7004d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.eslintignore @@ -0,0 +1,5 @@ +# Ignore File that will be copied to Angular +/files/ + +# Ignore sandbox enviroment +./sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.gitignore b/remote/test/puppeteer/packages/ng-schematics/.gitignore new file mode 100644 index 0000000000..9dad45cdd5 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.gitignore @@ -0,0 +1,3 @@ + +# Sandbox +sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs new file mode 100644 index 0000000000..be9bc29919 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs @@ -0,0 +1,6 @@ +module.exports = { + logLevel: 'debug', + spec: 'test/build/**/*.spec.js', + exit: !!process.env.CI, + reporter: process.env.CI ? 'spec' : 'dot', +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md new file mode 100644 index 0000000000..a483c4f2fb --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +## [0.5.6](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.5...ng-schematics-v0.5.6) (2024-01-16) + + +### Bug Fixes + +* jest config issue on Windows ([3711f86](https://github.com/puppeteer/puppeteer/commit/3711f86dca4140da9e830bd7a46f4eca43cd5f4b)) + +## [0.5.5](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.4...ng-schematics-v0.5.5) (2023-12-19) + + +### Bug Fixes + +* update documentation for ng-schematics ([#11533](https://github.com/puppeteer/puppeteer/issues/11533)) ([744e894](https://github.com/puppeteer/puppeteer/commit/744e8944ac62b9d7284fa260c5c796fa1b83b5ef)) + +## [0.5.4](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.3...ng-schematics-v0.5.4) (2023-12-06) + + +### Bug Fixes + +* get port from created server ([#11495](https://github.com/puppeteer/puppeteer/issues/11495)) ([d2f4b9c](https://github.com/puppeteer/puppeteer/commit/d2f4b9ca53642ac9ccae9a22fd3138698990387b)) + +## [0.5.3](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.2...ng-schematics-v0.5.3) (2023-12-04) + + +### Bug Fixes + +* ng-schematics install Windows ([#11487](https://github.com/puppeteer/puppeteer/issues/11487)) ([02af748](https://github.com/puppeteer/puppeteer/commit/02af7482d9bf2163b90dfe623b0af18c513d5a3b)) + +## [0.5.2](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.1...ng-schematics-v0.5.2) (2023-11-16) + + +### Bug Fixes + +* run post-install hooks ([#11403](https://github.com/puppeteer/puppeteer/issues/11403)) ([3f6ca24](https://github.com/puppeteer/puppeteer/commit/3f6ca249ed898eee25015a6fd0ce7cf774ad31b2)) + +## [0.5.1](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.0...ng-schematics-v0.5.1) (2023-11-13) + + +### Bug Fixes + +* multi-app project extend root `tsconfig.json` ([#11374](https://github.com/puppeteer/puppeteer/issues/11374)) ([1b2d920](https://github.com/puppeteer/puppeteer/commit/1b2d920fe638f3aad704ab8f21d1e4f4099b6d44)) +* support Angular 17 new template ([#11375](https://github.com/puppeteer/puppeteer/issues/11375)) ([64f7bf0](https://github.com/puppeteer/puppeteer/commit/64f7bf0af442369a07352b11555ec3f612eb62b8)) + +## [0.5.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.4.0...ng-schematics-v0.5.0) (2023-08-22) + + +### Features + +* **ng-schematics:** reduce the user options and better defaults ([35dc2d8](https://github.com/puppeteer/puppeteer/commit/35dc2d884052b27a3f9c70b8646f95743be7b84d)) +* **ng-schematics:** release version 0.5.0 ([#10768](https://github.com/puppeteer/puppeteer/issues/10768)) ([42fdd0a](https://github.com/puppeteer/puppeteer/commit/42fdd0a733acb2a9af3878bfa8927252f68ed465)) + + +### Bug Fixes + +* **ng-schematics:** builder is responsible for resolving commands ([683e181](https://github.com/puppeteer/puppeteer/commit/683e18189c0aedad7deb9007055a1a38801bbf08)) +* **ng-schematics:** don't install for library projects ([1376b77](https://github.com/puppeteer/puppeteer/commit/1376b77a7ab2260c2fd236c3cf31abbd544193e8)) + +## [0.4.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.4.0) (2023-08-08) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-03) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-02) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.2.0...ng-schematics-v0.3.0) (2023-06-29) + + +### Features + +* add Test command ([#10443](https://github.com/puppeteer/puppeteer/issues/10443)) ([2d8993b](https://github.com/puppeteer/puppeteer/commit/2d8993b45b0a0c5943907fe69f865e1064a23d3c)) + + +### Bug Fixes + +* `port` option to run dev and e2e side-by-side ([#10458](https://github.com/puppeteer/puppeteer/issues/10458)) ([a43b346](https://github.com/puppeteer/puppeteer/commit/a43b346bfc7f0071fcead1abb7d7b46dcf3c27f9)) +* use Node test reporter ([#10464](https://github.com/puppeteer/puppeteer/issues/10464)) ([f778b1e](https://github.com/puppeteer/puppeteer/commit/f778b1e2a70f3d507ab2012d2918f5ed241a8d21)) + +## [0.2.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.1.0...ng-schematics-v0.2.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) + +### Features + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) + +## 0.1.0 (2022-11-23) + + +### Features + +* **ng-schematics:** Release @puppeteer/ng-schematics ([#9244](https://github.com/puppeteer/puppeteer/issues/9244)) ([be33929](https://github.com/puppeteer/puppeteer/commit/be33929770e473992ad49029e6d038d36591e108)) diff --git a/remote/test/puppeteer/packages/ng-schematics/README.md b/remote/test/puppeteer/packages/ng-schematics/README.md new file mode 100644 index 0000000000..975f74a704 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/README.md @@ -0,0 +1,230 @@ +# Puppeteer Angular Schematic + +Adds Puppeteer-based e2e tests to your Angular project. + +## Getting started + +Run the command below in an Angular CLI app directory and follow the prompts. + +> Note this will add the schematic as a dependency to your project. + +```bash +ng add @puppeteer/ng-schematics +``` + +Or you can use the same command followed by the [options](#options) below. + +Currently, this schematic supports the following test runners: + +- [**Jasmine**](https://jasmine.github.io/) +- [**Jest**](https://jestjs.io/) +- [**Mocha**](https://mochajs.org/) +- [**Node Test Runner**](https://nodejs.org/api/test.html) + +With the schematics installed you can run E2E tests: + +```bash +ng e2e +``` + +### Options + +When adding schematics to your project you can to provide following options: + +| Option | Description | Value | Required | +| --------------- | ------------------------------------------------------ | ------------------------------------------ | -------- | +| `--test-runner` | The testing framework to install along side Puppeteer. | `"jasmine"`, `"jest"`, `"mocha"`, `"node"` | `true` | + +## Creating a single test file + +Puppeteer Angular Schematic exposes a method to create a single test file. + +```bash +ng generate @puppeteer/ng-schematics:e2e "<TestName>" +``` + +### Running test server and dev server at the same time + +By default the E2E test will run the app on the same port as `ng start`. +To avoid this you can specify the port the an the `angular.json` +Update either `e2e` or `puppeteer` (depending on the initial setup) to: + +```json +{ + "e2e": { + "builder": "@puppeteer/ng-schematics:puppeteer", + "options": { + "commands": [...], + "devServerTarget": "sandbox:serve", + "testRunner": "<TestRunner>", + "port": 8080 + }, + ... +} +``` + +Now update the E2E test file `utils.ts` baseUrl to: + +```ts +const baseUrl = 'http://localhost:8080'; +``` + +## Contributing + +Check out our [contributing guide](https://pptr.dev/contributing) to get an overview of what you need to develop in the Puppeteer repo. + +### Sandbox smoke tests + +To make integration easier smoke test can be run with a single command, that will create a fresh install of Angular (single application and a milti application projects). Then it will install the schematics inside them and run the initial e2e tests: + +```bash +node tools/smoke.mjs +``` + +### Unit Testing + +The schematics utilize `@angular-devkit/schematics/testing` for verifying correct file creation and `package.json` updates. To execute the test suit: + +```bash +npm run test +``` + +## Migrating from Protractor + +### Entry point + +Puppeteer has its own [`browser`](https://pptr.dev/api/puppeteer.browser) that exposes the browser process. +A more closes comparison for Protractor's `browser` would be Puppeteer's [`page`](https://pptr.dev/api/puppeteer.page). + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + await page + .locator('my-component') + // Click on the element once found + .click(); + }); +}); +``` + +### Getting element properties + +You can easily get any property of the element. + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + const elementText = await page + .locator('.my-component') + .map(button => button.innerText) + // Wait for element to show up + .wait(); + + // Assert via assertion library + }); +}); +``` + +### Query Selectors + +Puppeteer supports multiple types of selectors, namely, the CSS, ARIA, text, XPath and pierce selectors. +The following table shows Puppeteer's equivalents to [Protractor By](https://www.protractortest.org/#/api?view=ProtractorBy). + +> For improved reliability and reduced flakiness try our +> **Experimental** [Locators API](https://pptr.dev/guides/locators) + +| By | Protractor code | Puppeteer querySelector | +| ----------------- | --------------------------------------------- | ------------------------------------------------------------ | +| CSS (Single) | `$(by.css('<CSS>'))` | `page.$('<CSS>')` | +| CSS (Multiple) | `$$(by.css('<CSS>'))` | `page.$$('<CSS>')` | +| Id | `$(by.id('<ID>'))` | `page.$('#<ID>')` | +| CssContainingText | `$(by.cssContainingText('<CSS>', '<TEXT>'))` | `page.$('<CSS> ::-p-text(<TEXT>)')` ` | +| DeepCss | `$(by.deepCss('<CSS>'))` | `page.$(':scope >>> <CSS>')` | +| XPath | `$(by.xpath('<XPATH>'))` | `page.$('::-p-xpath(<XPATH>)')` | +| JS | `$(by.js('document.querySelector("<CSS>")'))` | `page.evaluateHandle(() => document.querySelector('<CSS>'))` | + +> For advanced use cases such as Protractor's `by.addLocator` you can check Puppeteer's [Custom selectors](https://pptr.dev/guides/query-selectors#custom-selectors). + +### Actions Selectors + +Puppeteer allows you to all necessary actions to allow test your application. + +```ts +// Click on the element. +element(locator).click(); +// Puppeteer equivalent +await page.locator(locator).click(); + +// Send keys to the element (usually an input). +element(locator).sendKeys('my text'); +// Puppeteer equivalent +await page.locator(locator).fill('my text'); + +// Clear the text in an element (usually an input). +element(locator).clear(); +// Puppeteer equivalent +await page.locator(locator).fill(''); + +// Get the value of an attribute, for example, get the value of an input. +element(locator).getAttribute('value'); +// Puppeteer equivalent +const element = await page.locator(locator).waitHandle(); +const value = await element.getProperty('value'); +``` + +### Example + +Sample Protractor test: + +```ts +describe('Protractor Demo', function () { + it('should add one and two', function () { + browser.get('http://juliemr.github.io/protractor-demo/'); + element(by.model('first')).sendKeys(1); + element(by.model('second')).sendKeys(2); + + element(by.id('gobutton')).click(); + + expect(element(by.binding('latest')).getText()).toEqual('3'); + }); +}); +``` + +Sample Puppeteer migration: + +```ts +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('Puppeteer Demo', function () { + setupBrowserHooks(); + it('should add one and two', function () { + const {page} = getBrowserState(); + await page.goto('http://juliemr.github.io/protractor-demo/'); + + await page.locator('.form-inline > input:nth-child(1)').fill('1'); + await page.locator('.form-inline > input:nth-child(2)').fill('2'); + await page.locator('#gobutton').fill('2'); + + const result = await page + .locator('.table tbody td:last-of-type') + .map(header => header.innerText) + .wait(); + + expect(result).toEqual('3'); + }); +}); +``` diff --git a/remote/test/puppeteer/packages/ng-schematics/package.json b/remote/test/puppeteer/packages/ng-schematics/package.json new file mode 100644 index 0000000000..29db1dcdc9 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/package.json @@ -0,0 +1,71 @@ +{ + "name": "@puppeteer/ng-schematics", + "version": "0.5.6", + "description": "Puppeteer Angular schematics", + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js", + "dev:test": "npm run test --watch", + "dev": "npm run build --watch", + "unit": "wireit" + }, + "wireit": { + "build": { + "command": "tsc -b && node tools/copySchemaFiles.mjs", + "clean": "if-file-deleted", + "files": [ + "tsconfig.json", + "tsconfig.test.json", + "src/**", + "test/src/**" + ], + "output": [ + "lib/**", + "test/build/**", + "*.tsbuildinfo" + ] + }, + "build:test": { + "command": "tsc -b test/tsconfig.json" + }, + "unit": { + "command": "node --test --test-reporter spec test/build", + "dependencies": [ + "build", + "build:test" + ] + } + }, + "keywords": [ + "angular", + "puppeteer", + "schematics" + ], + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/ng-schematics" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=16.13.2" + }, + "dependencies": { + "@angular-devkit/architect": "^0.1701.1", + "@angular-devkit/core": "^17.0.7", + "@angular-devkit/schematics": "^17.0.7" + }, + "devDependencies": { + "@schematics/angular": "^17.0.7", + "@angular/cli": "^17.0.7" + }, + "files": [ + "lib", + "!*.tsbuildinfo" + ], + "ng-add": { + "save": "devDependencies" + }, + "schematics": "./lib/schematics/collection.json", + "builders": "./lib/builders/builders.json" +} 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; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts new file mode 100644 index 0000000000..e4ec03ed54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts @@ -0,0 +1,30 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: config', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'single'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'multi'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + expect(tree.files).not.toContain( + getMultiApplicationFile('.puppeteerrc.mjs') + ); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts new file mode 100644 index 0000000000..8ae211cd59 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts @@ -0,0 +1,111 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: e2e', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).not.toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('${route}');` + ); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('home');` + ); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('${route}');`); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('home');`); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts new file mode 100644 index 0000000000..d912c5dc3d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts @@ -0,0 +1,260 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + MULTI_LIBRARY_OPTIONS, + buildTestingTree, + getAngularJsonScripts, + getMultiApplicationFile, + getMultiLibraryFile, + getPackageJson, + runSchematic, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: ng-add', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/tsconfig.json'); + expect(tree.files).toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/utils.ts'); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'single'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'single'); + + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jasmine.json'); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jest.config.js'); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.mocharc.js'); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.gitignore'); + expect(tree.files).not.toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/app.test.ts'); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'single'); + const tsConfigPath = '/e2e/tsconfig.json'; + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Application', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tsconfig.json') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/utils.ts') + ); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'multi'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiApplicationFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/jasmine.json')); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/jest.config.js') + ); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.mocharc.js')); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.gitignore')); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.test.ts') + ); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const tsConfigPath = getMultiApplicationFile('e2e/tsconfig.json'); + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../../../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Library', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const config = getAngularJsonScripts( + tree, + true, + MULTI_LIBRARY_OPTIONS.name + ); + + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tsconfig.json') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/utils.ts') + ); + expect(config).toBeUndefined(); + }); + + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiLibraryFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts new file mode 100644 index 0000000000..503cbd5cec --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts @@ -0,0 +1,147 @@ +import https from 'https'; +import {before, after} from 'node:test'; +import {join} from 'path'; + +import type {JsonObject} from '@angular-devkit/core'; +import { + SchematicTestRunner, + type UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import sinon from 'sinon'; + +const WORKSPACE_OPTIONS = { + name: 'workspace', + newProjectRoot: 'projects', + version: '14.0.0', +}; + +const SINGLE_APPLICATION_OPTIONS = { + name: 'sandbox', + directory: '.', + createApplication: true, + version: '14.0.0', +}; + +const MULTI_APPLICATION_OPTIONS = { + name: SINGLE_APPLICATION_OPTIONS.name, +}; + +export const MULTI_LIBRARY_OPTIONS = { + name: 'components', +}; + +export function setupHttpHooks(): void { + // Stop outgoing Request for version fetching + before(() => { + const httpsGetStub = sinon.stub(https, 'get'); + httpsGetStub.returns({ + on: (_: string, callback: () => void) => { + callback(); + }, + } as any); + }); + + after(() => { + sinon.restore(); + }); +} + +export function getAngularJsonScripts( + tree: UnitTestTree, + isDefault = true, + name = SINGLE_APPLICATION_OPTIONS.name +): { + builder: string; + configurations: Record<string, any>; + options: Record<string, any>; +} { + const angularJson = tree.readJson('angular.json') as any; + const e2eScript = isDefault ? 'e2e' : 'puppeteer'; + return angularJson['projects']?.[name]?.['architect'][e2eScript]; +} + +export function getPackageJson(tree: UnitTestTree): { + scripts: Record<string, string>; + devDependencies: string[]; +} { + const packageJson = tree.readJson('package.json') as JsonObject; + return { + scripts: packageJson['scripts'] as any, + devDependencies: Object.keys( + packageJson['devDependencies'] as Record<string, string> + ), + }; +} + +export function getMultiApplicationFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_APPLICATION_OPTIONS.name}/${file}`; +} +export function getMultiLibraryFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_LIBRARY_OPTIONS.name}/${file}`; +} + +export async function buildTestingTree( + command: 'ng-add' | 'e2e' | 'config', + type: 'single' | 'multi' = 'single', + userOptions?: Record<string, unknown> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + const options = { + testRunner: 'jasmine', + ...userOptions, + }; + let workingTree: UnitTestTree; + + // Build workspace + if (type === 'single') { + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'ng-new', + SINGLE_APPLICATION_OPTIONS + ); + } else { + // Build workspace + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'workspace', + WORKSPACE_OPTIONS + ); + // Build dummy application + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'application', + MULTI_APPLICATION_OPTIONS, + workingTree + ); + // Build dummy library + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'library', + MULTI_LIBRARY_OPTIONS, + workingTree + ); + } + + if (command !== 'ng-add') { + // We want to create update the proper files with `ng-add` + // First else the angular.json will have wrong data + workingTree = await runner.runSchematic('ng-add', options, workingTree); + } + + return await runner.runSchematic(command, options, workingTree); +} + +export async function runSchematic( + tree: UnitTestTree, + command: 'ng-add' | 'test', + options?: Record<string, any> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + return await runner.runSchematic(command, options, tree); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json new file mode 100644 index 0000000000..3d45f9cc54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "src/", + "outDir": "build/", + "types": ["node"], + }, + "include": ["src/**/*"], + "references": [{"path": "../tsconfig.json"}], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs new file mode 100644 index 0000000000..2bd88f229a --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import url from 'url'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +/** + * + * @param {String} directory + * @param {String[]} files + */ +async function findSchemaFiles(directory, files = []) { + const items = await fs.readdir(directory); + const promises = []; + // Match any listing that has no *.* format + // Ignore files folder + const regEx = /^.*\.[^\s]*$/; + + items.forEach(item => { + if (!item.match(regEx)) { + promises.push(findSchemaFiles(`${directory}/${item}`, files)); + } else if (item.endsWith('.json') || directory.includes('files')) { + files.push(`${directory}/${item}`); + } + }); + + await Promise.all(promises); + + return files; +} + +async function copySchemaFiles() { + const srcDir = path.join(__dirname, '..', 'src'); + const outputDir = path.join(__dirname, '..', 'lib'); + const files = await findSchemaFiles(srcDir); + + const moves = files.map(file => { + const to = file.replace(srcDir, outputDir); + + return {from: file, to}; + }); + + // Because fs.cp is Experimental (recursive support) + // We need to create directories first and copy the files + await Promise.all( + moves.map(({to}) => { + const dir = path.dirname(to); + return fs.mkdir(dir, {recursive: true}); + }) + ); + await Promise.all( + moves.map(({from, to}) => { + return fs.copyFile(from, to); + }) + ); +} + +copySchemaFiles(); diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs new file mode 100644 index 0000000000..985200881e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn} from 'child_process'; +import {randomUUID} from 'crypto'; +import {readFile, writeFile} from 'fs/promises'; +import {join} from 'path'; +import {cwd} from 'process'; + +class AngularProject { + static ports = new Set(); + static randomPort() { + const min = 4000; + const max = 9876; + return Math.floor(Math.random() * (max - min + 1) + min); + } + static port() { + const port = AngularProject.randomPort(); + if (AngularProject.ports.has(port)) { + return AngularProject.port(); + } + return port; + } + + static #scripts = testRunner => { + return { + // Builds the ng-schematics before running them + 'build:schematics': 'npm run --prefix ../../ build', + // Deletes all files created by Puppeteer Ng-Schematics to avoid errors + 'delete:file': + 'rm -f .puppeteerrc.cjs && rm -f tsconfig.e2e.json && rm -R -f e2e/', + // Runs the Puppeteer Ng-Schematics against the sandbox + schematics: 'schematics ../../:ng-add --dry-run=false', + 'schematics:e2e': 'schematics ../../:e2e --dry-run=false', + 'schematics:config': 'schematics ../../:config --dry-run=false', + 'schematics:smoke': `schematics ../../:ng-add --dry-run=false --test-runner="${testRunner}" && ng e2e`, + }; + }; + /** Folder name */ + #name; + /** E2E test runner to use */ + #runner; + + constructor(runner, name) { + this.#runner = runner ?? 'node'; + this.#name = name ?? randomUUID(); + } + + get runner() { + return this.#runner; + } + + get name() { + return this.#name; + } + + async executeCommand(command, options) { + const [executable, ...args] = command.split(' '); + await new Promise((resolve, reject) => { + const createProcess = spawn(executable, args, { + shell: true, + ...options, + }); + + createProcess.stdout.on('data', data => { + data = data + .toString() + // Replace new lines with a prefix including the test runner + .replace(/(?:\r\n?|\n)(?=.*[\r\n])/g, `\n${this.#runner} - `); + console.log(`${this.#runner} - ${data}`); + }); + + createProcess.on('error', message => { + console.error(`Running ${command} exited with error:`, message); + reject(message); + }); + + createProcess.on('exit', code => { + if (code === 0) { + resolve(true); + } else { + reject(); + } + }); + }); + } + + async create() { + await this.createProject(); + await this.updatePackageJson(); + } + + async updatePackageJson() { + const packageJsonFile = join(cwd(), `/sandbox/${this.#name}/package.json`); + const packageJson = JSON.parse(await readFile(packageJsonFile)); + packageJson['scripts'] = { + ...packageJson['scripts'], + ...AngularProject.#scripts(this.#runner), + }; + await writeFile(packageJsonFile, JSON.stringify(packageJson, null, 2)); + } + + get commandOptions() { + return { + ...process.env, + cwd: join(cwd(), `/sandbox/${this.#name}/`), + }; + } + + async runNpmScripts(command) { + await this.executeCommand(`npm run ${command}`, this.commandOptions); + } + + async runSchematics() { + await this.runNpmScripts('schematics'); + } + + async runSchematicsE2E() { + await this.runNpmScripts('schematics:e2e'); + } + + async runSchematicsConfig() { + await this.runNpmScripts('schematics:config'); + } + + async runSmoke() { + await this.runNpmScripts( + `schematics:smoke -- --port=${AngularProject.port()}` + ); + } +} + +export class AngularProjectSingle extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --directory=sandbox/${this.name} --defaults --skip-git` + ); + } +} + +export class AngularProjectMulti extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --create-application=false --directory=sandbox/${this.name} --defaults --skip-git` + ); + + await this.executeCommand( + `ng generate application core --style=css --routing=true`, + this.commandOptions + ); + await this.executeCommand( + `ng generate application admin --style=css --routing=false`, + this.commandOptions + ); + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs new file mode 100644 index 0000000000..8ae9907266 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ok} from 'node:assert'; +import {execSync} from 'node:child_process'; +import {parseArgs} from 'node:util'; + +import {AngularProjectMulti, AngularProjectSingle} from './projects.mjs'; + +const {values: args} = parseArgs({ + options: { + testRunner: { + type: 'string', + short: 't', + default: undefined, + }, + name: { + type: 'string', + short: 'n', + default: undefined, + }, + }, +}); + +if (process.env.CI) { + // Need to install in CI + execSync('npm install -g @angular/cli@latest @angular-devkit/schematics-cli'); + const runners = ['node', 'jest', 'jasmine', 'mocha']; + const groups = []; + + for (const runner of runners) { + groups.push([ + new AngularProjectSingle(runner), + new AngularProjectMulti(runner), + ]); + } + + const angularProjects = await Promise.allSettled( + groups.flat().map(async project => { + return await project.create(); + }) + ); + ok( + angularProjects.every(project => { + return project.status === 'fulfilled'; + }), + 'Building of 1 or more projects failed!' + ); + + for await (const runnerGroup of groups) { + const smokeResults = await Promise.allSettled( + runnerGroup.map(async project => { + return await project.runSmoke(); + }) + ); + ok( + smokeResults.every(project => { + return project.status === 'fulfilled'; + }), + `Smoke test for ${runnerGroup[0].runner} failed!` + ); + } +} else { + const single = new AngularProjectSingle(args.testRunner, args.name); + const multi = new AngularProjectMulti(args.testRunner, args.name); + + await Promise.all([single.create(), multi.create()]); + await Promise.all([single.runSmoke(), multi.runSmoke()]); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json new file mode 100644 index 0000000000..40529c7d17 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "tsconfig", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "rootDir": "src/", + "outDir": "lib/", + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "types": ["node"], + }, + "include": ["src/**/*"], + "exclude": ["src/**/files/**/*"], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsdoc.json b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} |